Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77c188c640 | ||
|
|
2a59817f51 | ||
|
|
9c450a0352 | ||
|
|
ab7dfbd859 | ||
|
|
dda2bd3ca5 | ||
|
|
ceea6d909e | ||
|
|
ecf34ba51a | ||
|
|
ebbd5d5373 |
@@ -26,6 +26,7 @@ func init() {
|
|||||||
mailsCmd.AddCommand(mailsDraftCmd())
|
mailsCmd.AddCommand(mailsDraftCmd())
|
||||||
mailsCmd.AddCommand(mailsAttachCmd())
|
mailsCmd.AddCommand(mailsAttachCmd())
|
||||||
mailsCmd.AddCommand(mailsDraftInvoiceCmd())
|
mailsCmd.AddCommand(mailsDraftInvoiceCmd())
|
||||||
|
mailsCmd.AddCommand(mailsSendCmd())
|
||||||
mailsCmd.AddCommand(mailsDeleteCmd())
|
mailsCmd.AddCommand(mailsDeleteCmd())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,6 +348,71 @@ func writeMailAttachment(path string, body []byte) error {
|
|||||||
return os.WriteFile(path, body, 0o644)
|
return os.WriteFile(path, body, 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mailsSendCmd() *cobra.Command {
|
||||||
|
var from, to, cc, bcc, subject, body, html string
|
||||||
|
var id int64
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "send",
|
||||||
|
Short: "Send a mail message (OnlyOffice Mail)",
|
||||||
|
Long: `Send via PUT /api/2.0/mail/messages/send.json.
|
||||||
|
|
||||||
|
oo mails send --id 7803 --body "…" # send referencing a draft id
|
||||||
|
oo mails send --to a@b.com --subject "…" --body "…"
|
||||||
|
oo mails send --id 7803 --to a@b.com --subject "…" --body "…" --cc x@y.com
|
||||||
|
|
||||||
|
IMPORTANT: send.json does NOT copy subject/body from the referenced draft — the
|
||||||
|
content must be in this request (--subject/--body). Cc/Bcc are omitted when empty
|
||||||
|
(the API 400s on empty strings). The API send does not append the UI signature —
|
||||||
|
put the chat line in --body if needed.
|
||||||
|
`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if to == "" && id == 0 {
|
||||||
|
return fmt.Errorf("--to is required (or --id of an existing draft)")
|
||||||
|
}
|
||||||
|
htmlBody := body
|
||||||
|
if html != "" {
|
||||||
|
htmlBody = html
|
||||||
|
}
|
||||||
|
if htmlBody == "" && id != 0 {
|
||||||
|
// The send.json endpoint does NOT copy subject/body from the
|
||||||
|
// referenced draft — an empty body here sends an empty message.
|
||||||
|
// Warn instead of silently mailing an empty email.
|
||||||
|
return fmt.Errorf("--body/--html is required when sending by --id (send.json needs the content in the request)")
|
||||||
|
}
|
||||||
|
if htmlBody == "" && to == "" {
|
||||||
|
return fmt.Errorf("--body is required for a fresh message")
|
||||||
|
}
|
||||||
|
c, err := newOO(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw, err := c.SendMail(cmd.Context(), onlyoffice.SendMailParams{
|
||||||
|
ID: id,
|
||||||
|
From: from,
|
||||||
|
To: to,
|
||||||
|
Cc: cc,
|
||||||
|
Bcc: bcc,
|
||||||
|
Subject: subject,
|
||||||
|
Body: htmlBody,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(string(raw))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().Int64Var(&id, "id", 0, "existing draft id to send (0 = fresh message)")
|
||||||
|
cmd.Flags().StringVar(&from, "from", "", "from address (default: first enabled mailbox)")
|
||||||
|
cmd.Flags().StringVar(&to, "to", "", "recipient (required unless --id)")
|
||||||
|
cmd.Flags().StringVar(&cc, "cc", "", "cc")
|
||||||
|
cmd.Flags().StringVar(&bcc, "bcc", "", "bcc")
|
||||||
|
cmd.Flags().StringVar(&subject, "subject", "", "subject")
|
||||||
|
cmd.Flags().StringVar(&body, "body", "", "plain text or HTML body")
|
||||||
|
cmd.Flags().StringVar(&html, "html", "", "HTML body (alias of --body when set)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
func mailsDeleteCmd() *cobra.Command {
|
func mailsDeleteCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "delete ID [ID...]",
|
Use: "delete ID [ID...]",
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// History entities that OnlyOffice CRM actually accepts for history notes.
|
||||||
|
// There is NO person/contact history in this API version: POST /api/2.0/crm/history.json
|
||||||
|
// returns 400 "Value does not fall within the expected range." for entityType
|
||||||
|
// contact/person/people/client/member. Verified against a live instance (#74).
|
||||||
|
const (
|
||||||
|
HistoryEntityOpportunity = "opportunity"
|
||||||
|
HistoryEntityCase = "case"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsCompany reports whether a CRM contact row is a company (vs a person).
|
||||||
|
// The field arrives as JSON bool; be liberal about what we accept.
|
||||||
|
func IsCompany(person map[string]any) bool {
|
||||||
|
b, _ := person["isCompany"].(bool)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContactID returns the CRM id of a contact row as a plain string.
|
||||||
|
func ContactID(row map[string]any) string {
|
||||||
|
return fmt.Sprint(row["id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildContactEmailIndex scans all persons once and maps lowercase email →
|
||||||
|
// contact id. Use this instead of calling FindPersonByEmail per address:
|
||||||
|
// the index is O(N) over the whole CRM, the per-address lookup is O(N×M).
|
||||||
|
func (c *Client) BuildContactEmailIndex(ctx context.Context) (map[string]string, error) {
|
||||||
|
all, err := c.ListAllContacts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
index := make(map[string]string, len(all)*2)
|
||||||
|
for _, person := range all {
|
||||||
|
if IsCompany(person) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := ContactID(person)
|
||||||
|
for _, row := range ContactInfoRows(person) {
|
||||||
|
if NormalizeContactInfoType(fmt.Sprint(row["infoType"])) != "email" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
email := strings.ToLower(strings.TrimSpace(fmt.Sprint(row["data"])))
|
||||||
|
if email != "" && email != "<nil>" {
|
||||||
|
index[email] = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildPersonOpportunityIndex maps every opportunity member's contact id to a
|
||||||
|
// deterministic representative opportunity: the one with the lowest numeric id.
|
||||||
|
// OnlyOffice has no person-level history, so notes for a person go on their
|
||||||
|
// deal — this index answers "which deal" in one pass.
|
||||||
|
func (c *Client) BuildPersonOpportunityIndex(ctx context.Context) (map[string]string, error) {
|
||||||
|
opps, err := c.ListAllOpportunities(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
index := map[string]string{}
|
||||||
|
for _, opp := range opps {
|
||||||
|
oppID := ContactID(opp)
|
||||||
|
for _, member := range OpportunityMembers(opp) {
|
||||||
|
pid := ContactID(member)
|
||||||
|
if cur, ok := index[pid]; !ok || NumericIDLess(oppID, cur) {
|
||||||
|
index[pid] = oppID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NumericIDLess compares two string ids numerically when possible, falling
|
||||||
|
// back to lexicographic order so results stay deterministic either way.
|
||||||
|
func NumericIDLess(a, b string) bool {
|
||||||
|
na, errA := strconv.Atoi(strings.TrimSpace(a))
|
||||||
|
nb, errB := strconv.Atoi(strings.TrimSpace(b))
|
||||||
|
if errA == nil && errB == nil && na != nb {
|
||||||
|
return na < nb
|
||||||
|
}
|
||||||
|
return a < b
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortIDs orders id strings deterministically (numeric first, then lexical).
|
||||||
|
func SortIDs(ids []string) {
|
||||||
|
sort.Strings(ids)
|
||||||
|
sort.SliceStable(ids, func(i, j int) bool { return NumericIDLess(ids[i], ids[j]) })
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsCompany(t *testing.T) {
|
||||||
|
if IsCompany(map[string]any{"isCompany": true}) != true {
|
||||||
|
t.Fatal("true row not detected")
|
||||||
|
}
|
||||||
|
if IsCompany(map[string]any{"isCompany": false}) {
|
||||||
|
t.Fatal("false row detected as company")
|
||||||
|
}
|
||||||
|
if IsCompany(map[string]any{}) {
|
||||||
|
t.Fatal("missing field detected as company")
|
||||||
|
}
|
||||||
|
if IsCompany(nil) {
|
||||||
|
t.Fatal("nil row detected as company")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNumericIDLess(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
a, b string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"9", "10", true},
|
||||||
|
{"1747", "1748", true},
|
||||||
|
{"abc", "abd", true},
|
||||||
|
{"10", "9", false},
|
||||||
|
{" 12 ", "13", true},
|
||||||
|
{"x1", "2", false}, // non-numeric falls back lexical: "x1" > "2"
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := NumericIDLess(c.a, c.b); got != c.want {
|
||||||
|
t.Errorf("NumericIDLess(%q,%q)=%v want %v", c.a, c.b, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSortIDs(t *testing.T) {
|
||||||
|
ids := []string{"20", "3", "100", "1"}
|
||||||
|
SortIDs(ids)
|
||||||
|
want := "1 3 20 100"
|
||||||
|
got := ""
|
||||||
|
for i, id := range ids {
|
||||||
|
if i > 0 {
|
||||||
|
got += " "
|
||||||
|
}
|
||||||
|
got += id
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("SortIDs=%q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContactID(t *testing.T) {
|
||||||
|
if ContactID(map[string]any{"id": float64(42)}) != "42" {
|
||||||
|
t.Fatal("numeric id formatting broken")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoryEntityConstants(t *testing.T) {
|
||||||
|
if HistoryEntityOpportunity != "opportunity" || HistoryEntityCase != "case" {
|
||||||
|
t.Fatal("history entity whitelist drifted from live-verified values")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -319,8 +319,18 @@ func (c *Client) resolveAPIURL(ref string) string {
|
|||||||
if ref == "" {
|
if ref == "" {
|
||||||
return ref
|
return ref
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
|
// Rewrite any host to the configured API base so downloads stay on the
|
||||||
return ref
|
// internal network and keep the Authorization header (no cross-host
|
||||||
|
// redirect that would strip it). Scheme-relative URLs are handled too.
|
||||||
|
if strings.HasPrefix(ref, "//") {
|
||||||
|
ref = "http:" + ref
|
||||||
|
}
|
||||||
|
if u, err := url.Parse(ref); err == nil && u.IsAbs() {
|
||||||
|
if base, err2 := url.Parse(c.baseURL()); err2 == nil {
|
||||||
|
u.Scheme = base.Scheme
|
||||||
|
u.Host = base.Host
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
base := c.baseURL()
|
base := c.baseURL()
|
||||||
if strings.HasPrefix(ref, "/") {
|
if strings.HasPrefix(ref, "/") {
|
||||||
|
|||||||
+421
@@ -0,0 +1,421 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
// WebDAV-oriented Files operations. These expose the Documents module through
|
||||||
|
// value types and cover everything needed to back a filesystem mapping:
|
||||||
|
// listing (including the virtual @root sections), folder/file CRUD, move/copy,
|
||||||
|
// and streaming upload/download. They are intentionally small and dependency
|
||||||
|
// free (only net/http), so callers are not forced to import heavier parts of
|
||||||
|
// the library.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DavFolder is a folder row from the Files module.
|
||||||
|
type DavFolder struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
ParentID string
|
||||||
|
RootType int // 1=Common, 3=Trash, 5=My, 6=Share, 8=Projects, ...
|
||||||
|
FilesCount int
|
||||||
|
FoldersCount int
|
||||||
|
Access int
|
||||||
|
Shared bool
|
||||||
|
Updated string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DavFile is a file row from the Files module.
|
||||||
|
type DavFile struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
Size int64
|
||||||
|
Updated string
|
||||||
|
ViewURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DavListing is the contents of one folder.
|
||||||
|
type DavListing struct {
|
||||||
|
Current DavFolder
|
||||||
|
Files []DavFile
|
||||||
|
Folders []DavFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDavFolder returns the contents of a folder by id, which may be a
|
||||||
|
// symbolic root such as "@my". For "@root" use ListDavSections.
|
||||||
|
func (c *Client) ListDavFolder(ctx context.Context, id string) (*DavListing, error) {
|
||||||
|
raw, err := c.getJSON(ctx, "/api/2.0/files/"+url.PathEscape(id))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := responseField(raw, "response")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// @root returns an array with a single blob; a normal folder returns an
|
||||||
|
// object. Normalize both.
|
||||||
|
if len(resp) > 0 && resp[0] == '[' {
|
||||||
|
var arr []*DavListing
|
||||||
|
if err := json.Unmarshal(resp, &arr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(arr) == 0 {
|
||||||
|
return &DavListing{}, nil
|
||||||
|
}
|
||||||
|
return arr[0], nil
|
||||||
|
}
|
||||||
|
var l DavListing
|
||||||
|
if err := json.Unmarshal(resp, &l); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDavSections returns the virtual top-level sections shown by @root
|
||||||
|
// ("In projects", "My documents", "Shared with me", "Common", "Favorites",
|
||||||
|
// "Recent", "Trash"). Each is the `current` folder of one @root element.
|
||||||
|
func (c *Client) ListDavSections(ctx context.Context) ([]DavFolder, error) {
|
||||||
|
raw, err := c.getJSON(ctx, "/api/2.0/files/@root")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := responseField(raw, "response")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var arr []struct {
|
||||||
|
Current DavFolder `json:"current"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp, &arr); err != nil {
|
||||||
|
// Tolerate a non-array (single listing) response.
|
||||||
|
var single DavListing
|
||||||
|
if err2 := json.Unmarshal(resp, &single); err2 != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []DavFolder{single.Current}, nil
|
||||||
|
}
|
||||||
|
sections := make([]DavFolder, 0, len(arr))
|
||||||
|
for i := range arr {
|
||||||
|
sections = append(sections, arr[i].Current)
|
||||||
|
}
|
||||||
|
return sections, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDavFolder creates a folder titled title inside parentID.
|
||||||
|
func (c *Client) CreateDavFolder(ctx context.Context, parentID, title string) (*DavFolder, error) {
|
||||||
|
raw, err := c.postJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(parentID),
|
||||||
|
map[string]string{"title": title})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var env struct {
|
||||||
|
Response *DavFolder `json:"response"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &env); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if env.Response == nil {
|
||||||
|
return nil, fmt.Errorf("onlyoffice: empty create-folder response")
|
||||||
|
}
|
||||||
|
return env.Response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameDavFolder renames a folder.
|
||||||
|
func (c *Client) RenameDavFolder(ctx context.Context, id, title string) error {
|
||||||
|
_, err := c.putJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(id),
|
||||||
|
map[string]string{"title": title})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameDavFile renames a file (title includes the extension).
|
||||||
|
func (c *Client) RenameDavFile(ctx context.Context, id, title string) error {
|
||||||
|
_, err := c.putJSON(ctx, "/api/2.0/files/file/"+url.PathEscape(id),
|
||||||
|
map[string]string{"title": title})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveDavItems moves the given folders and/or files into destFolderID.
|
||||||
|
func (c *Client) MoveDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error {
|
||||||
|
_, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", map[string]any{
|
||||||
|
"folderIds": nums(folderIDs),
|
||||||
|
"fileIds": nums(fileIDs),
|
||||||
|
"destFolderId": num(destFolderID),
|
||||||
|
"resolveType": "Skip",
|
||||||
|
"holdResult": true,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyDavItems copies the given folders and/or files into destFolderID.
|
||||||
|
func (c *Client) CopyDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error {
|
||||||
|
_, err := c.putJSON(ctx, "/api/2.0/files/fileops/copy", map[string]any{
|
||||||
|
"folderIds": nums(folderIDs),
|
||||||
|
"fileIds": nums(fileIDs),
|
||||||
|
"destFolderId": num(destFolderID),
|
||||||
|
"conflictResolveType": "Skip",
|
||||||
|
"deleteAfter": true,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDavItems deletes the given folders and/or files.
|
||||||
|
func (c *Client) DeleteDavItems(ctx context.Context, folderIDs, fileIDs []string) error {
|
||||||
|
body := map[string]any{"DeleteAfter": true, "Immediately": false}
|
||||||
|
for _, id := range folderIDs {
|
||||||
|
if _, err := c.deleteJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(id), body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range fileIDs {
|
||||||
|
if _, err := c.deleteJSON(ctx, "/api/2.0/files/file/"+url.PathEscape(id), body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadDavFile uploads src (fileName) into folderID, streaming from src.
|
||||||
|
func (c *Client) UploadDavFile(ctx context.Context, folderID, fileName string, src io.Reader) (*DavFile, error) {
|
||||||
|
raw, err := c.uploadReader(ctx, "/api/2.0/files/"+url.PathEscape(folderID)+"/upload", "file", fileName, src)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var env struct {
|
||||||
|
Response *DavFile `json:"response"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &env); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if env.Response == nil {
|
||||||
|
return nil, fmt.Errorf("onlyoffice: empty upload response")
|
||||||
|
}
|
||||||
|
return env.Response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadDavFile streams the file identified by id to w, returning bytes copied.
|
||||||
|
func (c *Client) DownloadDavFile(ctx context.Context, id string, w io.Writer) (int64, error) {
|
||||||
|
file, err := c.GetFile(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if file.ViewURL == nil || *file.ViewURL == "" {
|
||||||
|
return 0, fmt.Errorf("onlyoffice: file %s has no viewUrl", id)
|
||||||
|
}
|
||||||
|
u := c.resolveAPIURL(*file.ViewURL)
|
||||||
|
auth, err := c.authHeader()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", auth)
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return 0, fmt.Errorf("onlyoffice: download: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return io.Copy(w, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internal helpers -------------------------------------------------------
|
||||||
|
|
||||||
|
// deleteJSON performs an authenticated DELETE with an optional JSON body.
|
||||||
|
func (c *Client) deleteJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
|
||||||
|
var reader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
buf, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reader = bytes.NewReader(buf)
|
||||||
|
}
|
||||||
|
auth, err := c.authHeader()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL()+path, reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", auth)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
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("DELETE %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadReader uploads a stream to path under the given form field name.
|
||||||
|
func (c *Client) uploadReader(ctx context.Context, path, fieldName, fileName string, src io.Reader) (json.RawMessage, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
part, err := mw.CreateFormFile(fieldName, fileName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(part, src); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
auth, err := c.authHeader()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+path, &buf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", auth)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
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("upload %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nums(ids []string) []json.Number {
|
||||||
|
out := make([]json.Number, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if _, err := strconv.Atoi(id); err == nil {
|
||||||
|
out = append(out, json.Number(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func num(id string) any {
|
||||||
|
if _, err := strconv.Atoi(id); err == nil {
|
||||||
|
return json.Number(id)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON decodes a folder from the portal envelope, including fields
|
||||||
|
// that the base FolderEntry omits (parentId, rootFolderType, access, ...).
|
||||||
|
func (f *DavFolder) UnmarshalJSON(b []byte) error {
|
||||||
|
var raw struct {
|
||||||
|
ID *json.Number `json:"id"`
|
||||||
|
Title *string `json:"title"`
|
||||||
|
ParentID *json.Number `json:"parentId"`
|
||||||
|
RootType *int `json:"rootFolderType"`
|
||||||
|
FilesCount *int `json:"filesCount"`
|
||||||
|
FoldersCount *int `json:"foldersCount"`
|
||||||
|
Access *int `json:"access"`
|
||||||
|
Shared *bool `json:"shared"`
|
||||||
|
Updated *string `json:"updated"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if raw.ID != nil {
|
||||||
|
f.ID = raw.ID.String()
|
||||||
|
}
|
||||||
|
if raw.Title != nil {
|
||||||
|
f.Title = *raw.Title
|
||||||
|
}
|
||||||
|
if raw.ParentID != nil {
|
||||||
|
f.ParentID = raw.ParentID.String()
|
||||||
|
}
|
||||||
|
if raw.RootType != nil {
|
||||||
|
f.RootType = *raw.RootType
|
||||||
|
}
|
||||||
|
if raw.FilesCount != nil {
|
||||||
|
f.FilesCount = *raw.FilesCount
|
||||||
|
}
|
||||||
|
if raw.FoldersCount != nil {
|
||||||
|
f.FoldersCount = *raw.FoldersCount
|
||||||
|
}
|
||||||
|
if raw.Access != nil {
|
||||||
|
f.Access = *raw.Access
|
||||||
|
}
|
||||||
|
if raw.Shared != nil {
|
||||||
|
f.Shared = *raw.Shared
|
||||||
|
}
|
||||||
|
if raw.Updated != nil {
|
||||||
|
f.Updated = *raw.Updated
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON decodes a file row, capturing size and timestamps.
|
||||||
|
func (f *DavFile) UnmarshalJSON(b []byte) error {
|
||||||
|
var raw struct {
|
||||||
|
ID *json.Number `json:"id"`
|
||||||
|
Title *string `json:"title"`
|
||||||
|
PureSize *int64 `json:"pureContentLength"`
|
||||||
|
SizeStr *string `json:"contentLength"`
|
||||||
|
Updated *string `json:"updated"`
|
||||||
|
ViewURL *string `json:"viewUrl"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if raw.ID != nil {
|
||||||
|
f.ID = raw.ID.String()
|
||||||
|
}
|
||||||
|
if raw.Title != nil {
|
||||||
|
f.Title = *raw.Title
|
||||||
|
}
|
||||||
|
if raw.PureSize != nil {
|
||||||
|
f.Size = *raw.PureSize
|
||||||
|
} else if raw.SizeStr != nil {
|
||||||
|
if n, err := strconv.ParseInt(strings.Fields(*raw.SizeStr)[0], 10, 64); err == nil {
|
||||||
|
f.Size = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw.Updated != nil {
|
||||||
|
f.Updated = *raw.Updated
|
||||||
|
}
|
||||||
|
if raw.ViewURL != nil {
|
||||||
|
f.ViewURL = *raw.ViewURL
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModTime parses the folder's updated timestamp.
|
||||||
|
func (f *DavFolder) ModTime() time.Time {
|
||||||
|
t, _ := time.Parse("2006-01-02T15:04:05.0000000-07:00", f.Updated)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModTime parses the file's updated timestamp.
|
||||||
|
func (f *DavFile) ModTime() time.Time {
|
||||||
|
t, _ := time.Parse("2006-01-02T15:04:05.0000000-07:00", f.Updated)
|
||||||
|
return t
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/mail"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/mail"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -193,6 +193,49 @@ func (c *Client) SaveMailDraft(ctx context.Context, p SaveMailDraftParams) (map[
|
|||||||
return c.putJSONObject(ctx, "/api/2.0/mail/drafts/save", body)
|
return c.putJSONObject(ctx, "/api/2.0/mail/drafts/save", body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendMailParams describes a message to send via PUT /api/2.0/mail/messages/send.
|
||||||
|
// ID refers to an existing draft/message id; From falls back to the first enabled
|
||||||
|
// mailbox. Cc/Bcc are omitted when empty (the API 400s on empty strings). Chat
|
||||||
|
// line goes into Body (API send does not append the UI signature).
|
||||||
|
type SendMailParams struct {
|
||||||
|
ID int64
|
||||||
|
From string
|
||||||
|
To string
|
||||||
|
Cc string
|
||||||
|
Bcc string
|
||||||
|
Subject string
|
||||||
|
Body string // HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMail sends an existing draft (or a fresh message) via the OnlyOffice Mail
|
||||||
|
// send endpoint. Returns the raw send response.
|
||||||
|
func (c *Client) SendMail(ctx context.Context, p SendMailParams) (json.RawMessage, error) {
|
||||||
|
if strings.TrimSpace(p.To) == "" {
|
||||||
|
return nil, fmt.Errorf("SendMail: to is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.From) == "" {
|
||||||
|
from, err := c.defaultMailFrom(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.From = from
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"id": p.ID,
|
||||||
|
"from": p.From,
|
||||||
|
"to": p.To,
|
||||||
|
"subject": p.Subject,
|
||||||
|
"body": p.Body,
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.Cc) != "" {
|
||||||
|
body["cc"] = p.Cc
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.Bcc) != "" {
|
||||||
|
body["bcc"] = p.Bcc
|
||||||
|
}
|
||||||
|
return c.putJSON(ctx, "/api/2.0/mail/messages/send.json", body)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) defaultMailFrom(ctx context.Context) (string, error) {
|
func (c *Client) defaultMailFrom(ctx context.Context) (string, error) {
|
||||||
accounts, err := c.ListMailAccounts(ctx)
|
accounts, err := c.ListMailAccounts(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+56
-3
@@ -2,6 +2,7 @@ package onlyoffice
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -49,9 +50,9 @@ func TestMailMessagesPath(t *testing.T) {
|
|||||||
|
|
||||||
func TestParseMailAddress(t *testing.T) {
|
func TestParseMailAddress(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
raw string
|
raw string
|
||||||
wantName string
|
wantName string
|
||||||
wantAddress string
|
wantAddress string
|
||||||
}{
|
}{
|
||||||
{`"LinkedIn Jobbenachrichtigungen" <jobalerts-noreply@linkedin.com>`, "LinkedIn Jobbenachrichtigungen", "jobalerts-noreply@linkedin.com"},
|
{`"LinkedIn Jobbenachrichtigungen" <jobalerts-noreply@linkedin.com>`, "LinkedIn Jobbenachrichtigungen", "jobalerts-noreply@linkedin.com"},
|
||||||
{`"Bitfinex" <no-reply@bitfinex.com>`, "Bitfinex", "no-reply@bitfinex.com"},
|
{`"Bitfinex" <no-reply@bitfinex.com>`, "Bitfinex", "no-reply@bitfinex.com"},
|
||||||
@@ -159,3 +160,55 @@ func TestDownloadMailAttachmentUsesAuthCookie(t *testing.T) {
|
|||||||
t.Fatalf("path = %q", gotPath)
|
t.Fatalf("path = %q", gotPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendMailOmitsEmptyCcBcc(t *testing.T) {
|
||||||
|
var gotBody map[string]any
|
||||||
|
var gotPath string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/2.0/authentication.json":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"response":{"token":"tok","expires":"2099-01-01T00:00:00.0000000+00:00"}}`))
|
||||||
|
case "/api/2.0/mail/messages/send.json":
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
dec := json.NewDecoder(r.Body)
|
||||||
|
_ = dec.Decode(&gotBody)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"response":{"id":1}}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"})
|
||||||
|
ctx := context.Background()
|
||||||
|
raw, err := c.SendMail(ctx, SendMailParams{
|
||||||
|
ID: 99,
|
||||||
|
From: "me@x.com",
|
||||||
|
To: "a@b.com",
|
||||||
|
Subject: "hi",
|
||||||
|
Body: "<p>hello</p>",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendMail: %v", err)
|
||||||
|
}
|
||||||
|
if gotPath != "/api/2.0/mail/messages/send.json" {
|
||||||
|
t.Fatalf("path = %q", gotPath)
|
||||||
|
}
|
||||||
|
if _, hasCC := gotBody["cc"]; hasCC {
|
||||||
|
t.Fatalf("empty cc should be omitted: %v", gotBody)
|
||||||
|
}
|
||||||
|
if _, hasBcc := gotBody["bcc"]; hasBcc {
|
||||||
|
t.Fatalf("empty bcc should be omitted: %v", gotBody)
|
||||||
|
}
|
||||||
|
if gotBody["to"] != "a@b.com" {
|
||||||
|
t.Fatalf("to = %v", gotBody["to"])
|
||||||
|
}
|
||||||
|
if gotBody["id"] != float64(99) {
|
||||||
|
t.Fatalf("id = %v", gotBody["id"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), `"id"`) {
|
||||||
|
t.Fatalf("raw = %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
-175
@@ -1,175 +0,0 @@
|
|||||||
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{}
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
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