Merge pull request 'reconcile(github): sync GitHub-only commits into Gitea main (#144)' (#6) from reconcile/github-sync#144 into main
merge(github): sync GitHub-only commits into Gitea main (#144)
This commit was merged in pull request #6.
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user