diff --git a/cmd/oo/mails.go b/cmd/oo/mails.go index d6d5b4d..c49f362 100644 --- a/cmd/oo/mails.go +++ b/cmd/oo/mails.go @@ -26,6 +26,7 @@ func init() { mailsCmd.AddCommand(mailsDraftCmd()) mailsCmd.AddCommand(mailsAttachCmd()) mailsCmd.AddCommand(mailsDraftInvoiceCmd()) + mailsCmd.AddCommand(mailsSendCmd()) mailsCmd.AddCommand(mailsDeleteCmd()) } @@ -347,6 +348,71 @@ func writeMailAttachment(path string, body []byte) error { 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 { return &cobra.Command{ Use: "delete ID [ID...]", diff --git a/mails.go b/mails.go index 491f725..d15f821 100644 --- a/mails.go +++ b/mails.go @@ -8,8 +8,8 @@ import ( "encoding/json" "fmt" "io" - "net/mail" "net/http" + "net/mail" "net/url" "strconv" "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) } +// 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) { accounts, err := c.ListMailAccounts(ctx) if err != nil { diff --git a/mails_test.go b/mails_test.go index 86b2374..bf9a35c 100644 --- a/mails_test.go +++ b/mails_test.go @@ -2,6 +2,7 @@ package onlyoffice import ( "context" + "encoding/json" "net/http" "net/http/cookiejar" "net/http/httptest" @@ -49,9 +50,9 @@ func TestMailMessagesPath(t *testing.T) { func TestParseMailAddress(t *testing.T) { tests := []struct { - raw string - wantName string - wantAddress string + raw string + wantName string + wantAddress string }{ {`"LinkedIn Jobbenachrichtigungen" `, "LinkedIn Jobbenachrichtigungen", "jobalerts-noreply@linkedin.com"}, {`"Bitfinex" `, "Bitfinex", "no-reply@bitfinex.com"}, @@ -159,3 +160,55 @@ func TestDownloadMailAttachmentUsesAuthCookie(t *testing.T) { 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: "

hello

", + }) + 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) + } +}