diff --git a/README.md b/README.md index 78ff066..3fab0de 100644 --- a/README.md +++ b/README.md @@ -631,7 +631,7 @@ oo tasks files detach 208 12345 | `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` | | `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` | | `crm` | `cleanup` | -| `mails` | `accounts`, `folders`, `list`, `get`, `delete` | +| `mails` | `accounts`, `folders`, `list`, `get`, `draft`, `attach`, `draft-invoice`, `delete` | | `cases` | `list`, `create`, `delete`, `member-add` | | `crm-tasks` | `list`, `create`, `delete`, `categories` | | `applications` | `sync` | @@ -821,6 +821,16 @@ oo mails get 5664 -o json | jq '{subject, from, to, date}' # Remove one or more messages (server moves to trash or deletes per Mail rules) oo mails delete 5664 oo mails delete 5664 5663 5661 + +# Create / update a draft (HTML body field is API "body"; plain text is wrapped) +oo mails draft --to client@example.com --subject "Rechnung INV-2026-01" \ + --body "Guten Tag,\n\nanbei die Rechnung.\n\nMit freundlichen Grüßen" + +# Attach an OnlyOffice Files document (e.g. invoice PDF file id) to a draft +oo mails attach 7301 --file-id 12345 + +# Regenerate invoice PDF + draft + attach (does not send) +oo mails draft-invoice --invoice 16 --to info@example.com ``` **Table output** splits the `from` header into `fromName` and `fromAddress` diff --git a/cmd/oo/mails.go b/cmd/oo/mails.go index 4e7f254..9dd238c 100644 --- a/cmd/oo/mails.go +++ b/cmd/oo/mails.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "strconv" "strings" @@ -11,7 +12,7 @@ import ( var mailsCmd = &cobra.Command{ Use: "mails", Aliases: []string{"mail"}, - Short: "OnlyOffice Workspace mail — list, read, delete", + Short: "OnlyOffice Workspace mail — list, read, draft, delete", } func init() { @@ -20,6 +21,9 @@ func init() { mailsCmd.AddCommand(mailsFoldersCmd()) mailsCmd.AddCommand(mailsListCmd()) mailsCmd.AddCommand(mailsGetCmd()) + mailsCmd.AddCommand(mailsDraftCmd()) + mailsCmd.AddCommand(mailsAttachCmd()) + mailsCmd.AddCommand(mailsDraftInvoiceCmd()) mailsCmd.AddCommand(mailsDeleteCmd()) } @@ -126,6 +130,176 @@ func mailsGetCmd() *cobra.Command { } } +func mailsDraftCmd() *cobra.Command { + var from, to, cc, bcc, subject, body, html string + var id int64 + cmd := &cobra.Command{ + Use: "draft", + Short: "Create or update a mail draft (OnlyOffice Mail)", + Long: `Save a draft in /addons/mail (PUT /api/2.0/mail/drafts/save). + + oo mails draft --to a@b.com --subject "…" --body "

…

" + oo mails draft --id 123 --to a@b.com --subject "…" --html "

…

" +`, + RunE: func(cmd *cobra.Command, args []string) error { + if to == "" { + return fmt.Errorf("--to is required") + } + htmlBody := body + if html != "" { + htmlBody = html + } + if htmlBody == "" { + return fmt.Errorf("--body or --html is required") + } + c, err := newOO(cmd) + if err != nil { + return err + } + out, err := c.SaveMailDraft(cmd.Context(), onlyoffice.SaveMailDraftParams{ + ID: id, + From: from, + To: to, + Cc: cc, + Bcc: bcc, + Subject: subject, + Body: onlyoffice.PlainTextToMailHTML(htmlBody), + }) + if err != nil { + return err + } + printObject(out) + return nil + }, + } + cmd.Flags().Int64Var(&id, "id", 0, "existing draft id (0 = create)") + cmd.Flags().StringVar(&from, "from", "", "from address (default: first enabled mailbox)") + cmd.Flags().StringVar(&to, "to", "", "recipient (required)") + 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 mailsAttachCmd() *cobra.Command { + var fileID int64 + cmd := &cobra.Command{ + Use: "attach MESSAGE_ID", + Short: "Attach an OnlyOffice Files document to a draft/message", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if fileID <= 0 { + return fmt.Errorf("--file-id is required") + } + c, err := newOO(cmd) + if err != nil { + return err + } + out, err := c.AttachMailDocument(cmd.Context(), args[0], fileID) + if err != nil { + return err + } + printObject(out) + return nil + }, + } + cmd.Flags().Int64Var(&fileID, "file-id", 0, "OnlyOffice file id (e.g. invoice PDF)") + return cmd +} + +func mailsDraftInvoiceCmd() *cobra.Command { + var to, from, subject, body string + var invoiceID int + cmd := &cobra.Command{ + Use: "draft-invoice", + Short: "Create a mail draft with regenerated invoice PDF attached", + Long: `Regenerate the CRM invoice PDF, save an OnlyOffice Mail draft, and attach the PDF. + +Does not send. Open /addons/mail/#drafts to review. + + oo mails draft-invoice --invoice 16 --to info@example.com +`, + RunE: func(cmd *cobra.Command, args []string) error { + if invoiceID <= 0 || to == "" { + return fmt.Errorf("--invoice and --to are required") + } + c, err := newOO(cmd) + if err != nil { + return err + } + pdf, err := c.InvoicePDFFile(cmd.Context(), strconv.Itoa(invoiceID)) + if err != nil { + return err + } + fileID := onlyoffice.Int64FromMap(pdf, "id") + if fileID <= 0 { + return fmt.Errorf("invoice PDF has no file id: %+v", pdf) + } + inv, err := c.GetInvoice(cmd.Context(), strconv.Itoa(invoiceID)) + if err != nil { + return err + } + number := strings.TrimSpace(fmt.Sprint(inv["number"])) + cost := formatInvoiceCostEUR(inv["cost"]) + if subject == "" { + subject = fmt.Sprintf("Rechnung %s — Service package (%s EUR)", number, cost) + } + if body == "" { + body = fmt.Sprintf(`Guten Tag, + +anbei übersende ich die Rechnung %s über %s EUR +für das Service package (OS image inkl. Backup, Monitoring, RustDesk, +gemeinsamem Datenträger und Netzwerkzugriff). + +Zahlungsziel: 14 Tage netto. +Steuerhinweis: siehe Notizen auf der Rechnung (Reverse Charge / § 13b UStG). + +Mit freundlichen Grüßen +Example Author`, number, cost) + } + draft, err := c.SaveMailDraft(cmd.Context(), onlyoffice.SaveMailDraftParams{ + From: from, + To: to, + Subject: subject, + Body: onlyoffice.PlainTextToMailHTML(body), + }) + if err != nil { + return err + } + mid := fmt.Sprint(draft["id"]) + att, err := c.AttachMailDocument(cmd.Context(), mid, fileID) + if err != nil { + return fmt.Errorf("draft %s created but attach failed: %w", mid, err) + } + if outputFormat == "json" { + printObject(map[string]any{"draft": draft, "attachment": att, "pdfFileId": fileID}) + return nil + } + fmt.Printf("draft %s to=%s subject=%q pdfFileId=%d\n", mid, to, subject, fileID) + fmt.Printf("open: https://office.example.com/addons/mail/#drafts\n") + return nil + }, + } + cmd.Flags().IntVar(&invoiceID, "invoice", 0, "CRM invoice id") + cmd.Flags().StringVar(&to, "to", "", "recipient") + cmd.Flags().StringVar(&from, "from", "", "from address (default mailbox)") + cmd.Flags().StringVar(&subject, "subject", "", "override subject") + cmd.Flags().StringVar(&body, "body", "", "override plain-text body") + return cmd +} + +func formatInvoiceCostEUR(v any) string { + s := strings.TrimSpace(fmt.Sprint(v)) + s = strings.TrimSuffix(s, ".00") + s = strings.TrimSuffix(s, ".0") + if s == "" || s == "" { + return "?" + } + return s +} + func mailsDeleteCmd() *cobra.Command { return &cobra.Command{ Use: "delete ID [ID...]", diff --git a/mails.go b/mails.go index ac73997..5ac48a4 100644 --- a/mails.go +++ b/mails.go @@ -5,6 +5,7 @@ package onlyoffice import ( "context" + "encoding/json" "fmt" "net/mail" "net/url" @@ -96,11 +97,156 @@ func (c *Client) GetMailMessage(ctx context.Context, messageID string) (map[stri } // RemoveMailMessages deletes messages by id (PUT /api/2.0/mail/messages/remove). +// The API response "response" field may be a number or object; success is HTTP 2xx. func (c *Client) RemoveMailMessages(ctx context.Context, ids ...int) (map[string]any, error) { if len(ids) == 0 { return nil, fmt.Errorf("RemoveMailMessages: at least one id is required") } - return c.putJSONObject(ctx, "/api/2.0/mail/messages/remove", map[string]any{"ids": ids}) + raw, err := c.putJSON(ctx, "/api/2.0/mail/messages/remove", map[string]any{"ids": ids}) + if err != nil { + return nil, err + } + out := map[string]any{"ok": true, "ids": ids} + if resp, err := responseField(raw, "response"); err == nil { + var n json.Number + if json.Unmarshal(resp, &n) == nil { + out["response"] = n.String() + } else { + var m map[string]any + if json.Unmarshal(resp, &m) == nil { + out["response"] = m + } else { + out["response"] = string(resp) + } + } + } + return out, nil +} + +// SaveMailDraftParams creates or updates a draft via PUT /api/2.0/mail/drafts/save. +// Id 0 creates a new draft. Body is HTML; the API field name is "body" (not htmlBody). +type SaveMailDraftParams struct { + ID int64 // 0 = create + From string // mailbox address, e.g. eslider@gmail.com + To string // comma-separated or single address + Cc string + Bcc string + Subject string + Body string // HTML +} + +// SaveMailDraft saves a draft message. Returns the saved message map (incl. id). +func (c *Client) SaveMailDraft(ctx context.Context, p SaveMailDraftParams) (map[string]any, error) { + if strings.TrimSpace(p.To) == "" { + return nil, fmt.Errorf("SaveMailDraft: 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, + "cc": p.Cc, + "bcc": p.Bcc, + "subject": p.Subject, + "body": p.Body, + } + return c.putJSONObject(ctx, "/api/2.0/mail/drafts/save", body) +} + +func (c *Client) defaultMailFrom(ctx context.Context) (string, error) { + accounts, err := c.ListMailAccounts(ctx) + if err != nil { + return "", err + } + var fallback string + for _, a := range accounts { + email := strings.TrimSpace(stringFromMap(a, "email")) + if email == "" || email == "" { + continue + } + if boolField(a, "enabled") || boolField(a, "isDefault") { + return email, nil + } + if fallback == "" { + fallback = email + } + } + if fallback == "" { + return "", fmt.Errorf("SaveMailDraft: from is required (no mailbox on account)") + } + return fallback, nil +} + +// AttachMailDocument attaches an OnlyOffice Files document to a mail message/draft. +// fileID is the Documents/CRM file id (e.g. from InvoicePDFFile). +func (c *Client) AttachMailDocument(ctx context.Context, messageID string, fileID int64) (map[string]any, error) { + id := strings.TrimSpace(messageID) + if id == "" { + return nil, fmt.Errorf("AttachMailDocument: message id is required") + } + if fileID <= 0 { + return nil, fmt.Errorf("AttachMailDocument: file id is required") + } + fields := url.Values{} + fields.Set("fileId", strconv.FormatInt(fileID, 10)) + return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/mail/messages/%s/document", url.PathEscape(id)), fields) +} + +// InvoicePDFFile regenerates/returns the invoice PDF file metadata (id, title, viewUrl). +func (c *Client) InvoicePDFFile(ctx context.Context, invoiceID string) (map[string]any, error) { + id := strings.TrimSpace(invoiceID) + if id == "" { + return nil, fmt.Errorf("InvoicePDFFile: invoice id is required") + } + return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/crm/invoice/%s/pdf", url.PathEscape(id))) +} + +// PlainTextToMailHTML turns plain text into simple HTML paragraphs for drafts. +// If s already looks like HTML, it is returned unchanged. +func PlainTextToMailHTML(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + if strings.Contains(s, "<") && strings.Contains(s, ">") { + return s + } + parts := strings.Split(s, "\n\n") + var b strings.Builder + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + b.WriteString("

") + b.WriteString(strings.ReplaceAll(p, "\n", "
")) + b.WriteString("

\n") + } + return b.String() +} + +// Int64FromMap coerces OnlyOffice numeric id fields (float64/int/string) to int64. +func Int64FromMap(m map[string]any, key string) int64 { + switch v := m[key].(type) { + case float64: + return int64(v) + case int64: + return v + case int: + return int64(v) + case string: + n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + return n + default: + n, _ := strconv.ParseInt(fmt.Sprint(m[key]), 10, 64) + return n + } } // ResolveMailFolder maps a CLI folder name or numeric string to a folder id. diff --git a/mails_test.go b/mails_test.go index 3302f0c..89714b9 100644 --- a/mails_test.go +++ b/mails_test.go @@ -1,6 +1,9 @@ package onlyoffice -import "testing" +import ( + "strings" + "testing" +) func TestResolveMailFolder(t *testing.T) { tests := []struct { @@ -70,3 +73,23 @@ func TestMailMessagesAsTableRows(t *testing.T) { t.Fatalf("got %+v", rows[0]) } } + +func TestPlainTextToMailHTML(t *testing.T) { + got := PlainTextToMailHTML("Hello\n\nWorld\nline2") + if !strings.Contains(got, "

Hello

") || !strings.Contains(got, "World
line2") { + t.Fatalf("got %q", got) + } + html := "

Already

" + if PlainTextToMailHTML(html) != html { + t.Fatalf("html passthrough failed") + } +} + +func TestInt64FromMap(t *testing.T) { + if Int64FromMap(map[string]any{"id": float64(99)}, "id") != 99 { + t.Fatal("float64") + } + if Int64FromMap(map[string]any{"id": "42"}, "id") != 42 { + t.Fatal("string") + } +}