Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae776aa396 | ||
|
|
20a09530cd | ||
|
|
65cd3f5c74 |
@@ -26,7 +26,6 @@ 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())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,71 +347,6 @@ 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...]",
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
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]) })
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
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,18 +319,8 @@ func (c *Client) resolveAPIURL(ref string) string {
|
|||||||
if ref == "" {
|
if ref == "" {
|
||||||
return ref
|
return ref
|
||||||
}
|
}
|
||||||
// Rewrite any host to the configured API base so downloads stay on the
|
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
|
||||||
// internal network and keep the Authorization header (no cross-host
|
return ref
|
||||||
// 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
@@ -1,421 +0,0 @@
|
|||||||
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/http"
|
|
||||||
"net/mail"
|
"net/mail"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -193,49 +193,6 @@ 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 {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package onlyoffice
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -160,55 +159,3 @@ 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
@@ -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