feat(mail): draft, attach, and draft-invoice CLI

Save OO Mail drafts via body field, attach Files documents, and one-shot
invoice PDF regenerate + draft for client billing emails.
This commit is contained in:
2026-08-06 13:18:02 +01:00
parent d81e7de911
commit 73b1050a83
4 changed files with 357 additions and 4 deletions
+11 -1
View File
@@ -631,7 +631,7 @@ oo tasks files detach 208 12345
| `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` | | `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` |
| `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` | | `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` |
| `crm` | `cleanup` | | `crm` | `cleanup` |
| `mails` | `accounts`, `folders`, `list`, `get`, `delete` | | `mails` | `accounts`, `folders`, `list`, `get`, `draft`, `attach`, `draft-invoice`, `delete` |
| `cases` | `list`, `create`, `delete`, `member-add` | | `cases` | `list`, `create`, `delete`, `member-add` |
| `crm-tasks` | `list`, `create`, `delete`, `categories` | | `crm-tasks` | `list`, `create`, `delete`, `categories` |
| `applications` | `sync` | | `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) # Remove one or more messages (server moves to trash or deletes per Mail rules)
oo mails delete 5664 oo mails delete 5664
oo mails delete 5664 5663 5661 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` **Table output** splits the `from` header into `fromName` and `fromAddress`
+175 -1
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"fmt"
"strconv" "strconv"
"strings" "strings"
@@ -11,7 +12,7 @@ import (
var mailsCmd = &cobra.Command{ var mailsCmd = &cobra.Command{
Use: "mails", Use: "mails",
Aliases: []string{"mail"}, Aliases: []string{"mail"},
Short: "OnlyOffice Workspace mail — list, read, delete", Short: "OnlyOffice Workspace mail — list, read, draft, delete",
} }
func init() { func init() {
@@ -20,6 +21,9 @@ func init() {
mailsCmd.AddCommand(mailsFoldersCmd()) mailsCmd.AddCommand(mailsFoldersCmd())
mailsCmd.AddCommand(mailsListCmd()) mailsCmd.AddCommand(mailsListCmd())
mailsCmd.AddCommand(mailsGetCmd()) mailsCmd.AddCommand(mailsGetCmd())
mailsCmd.AddCommand(mailsDraftCmd())
mailsCmd.AddCommand(mailsAttachCmd())
mailsCmd.AddCommand(mailsDraftInvoiceCmd())
mailsCmd.AddCommand(mailsDeleteCmd()) 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 "<p>…</p>"
oo mails draft --id 123 --to a@b.com --subject "…" --html "<p>…</p>"
`,
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 == "<nil>" {
return "?"
}
return s
}
func mailsDeleteCmd() *cobra.Command { func mailsDeleteCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "delete ID [ID...]", Use: "delete ID [ID...]",
+147 -1
View File
@@ -5,6 +5,7 @@ package onlyoffice
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"net/mail" "net/mail"
"net/url" "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). // 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) { func (c *Client) RemoveMailMessages(ctx context.Context, ids ...int) (map[string]any, error) {
if len(ids) == 0 { if len(ids) == 0 {
return nil, fmt.Errorf("RemoveMailMessages: at least one id is required") 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 == "<nil>" {
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("<p>")
b.WriteString(strings.ReplaceAll(p, "\n", "<br/>"))
b.WriteString("</p>\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. // ResolveMailFolder maps a CLI folder name or numeric string to a folder id.
+24 -1
View File
@@ -1,6 +1,9 @@
package onlyoffice package onlyoffice
import "testing" import (
"strings"
"testing"
)
func TestResolveMailFolder(t *testing.T) { func TestResolveMailFolder(t *testing.T) {
tests := []struct { tests := []struct {
@@ -70,3 +73,23 @@ func TestMailMessagesAsTableRows(t *testing.T) {
t.Fatalf("got %+v", rows[0]) t.Fatalf("got %+v", rows[0])
} }
} }
func TestPlainTextToMailHTML(t *testing.T) {
got := PlainTextToMailHTML("Hello\n\nWorld\nline2")
if !strings.Contains(got, "<p>Hello</p>") || !strings.Contains(got, "World<br/>line2") {
t.Fatalf("got %q", got)
}
html := "<p>Already</p>"
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")
}
}