From a15f032b3246c8fcc4cb2a6c7e08a8e6a88af174 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sat, 4 May 2024 10:15:30 +1200 Subject: [PATCH] Feature: API endpoint for sending (#278) --- internal/tools/utils.go | 17 ++- server/apiv1/api.go | 13 ++ server/apiv1/send.go | 275 ++++++++++++++++++++++++++++++++++ server/apiv1/swagger.go | 19 +++ server/server.go | 5 +- server/server_test.go | 139 +++++++++++++++++ server/smtpd/smtpd.go | 18 ++- server/ui/api/v1/swagger.json | 237 +++++++++++++++++++++++++++++ 8 files changed, 715 insertions(+), 8 deletions(-) create mode 100644 server/apiv1/send.go diff --git a/internal/tools/utils.go b/internal/tools/utils.go index f152c6e..2064f6f 100644 --- a/internal/tools/utils.go +++ b/internal/tools/utils.go @@ -1,6 +1,9 @@ package tools -import "fmt" +import ( + "fmt" + "strings" +) // Plural returns a singular or plural of a word together with the total func Plural(total int, singular, plural string) string { @@ -9,3 +12,15 @@ func Plural(total int, singular, plural string) string { } return fmt.Sprintf("%d %s", total, plural) } + +// InArray tests if a string is within an array. It is not case sensitive. +func InArray(k string, arr []string) bool { + k = strings.ToLower(k) + for _, v := range arr { + if strings.ToLower(v) == k { + return true + } + } + + return false +} diff --git a/server/apiv1/api.go b/server/apiv1/api.go index 24a3a66..e562876 100644 --- a/server/apiv1/api.go +++ b/server/apiv1/api.go @@ -900,6 +900,19 @@ func httpError(w http.ResponseWriter, msg string) { fmt.Fprint(w, msg) } +// httpJSONError returns a basic error message (400 response) in JSON format +func httpJSONError(w http.ResponseWriter, msg string) { + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy) + w.WriteHeader(http.StatusBadRequest) + e := JSONErrorMessage{ + Error: msg, + } + bytes, _ := json.Marshal(e) + w.Header().Add("Content-Type", "application/json") + _, _ = w.Write(bytes) +} + // Get the start and limit based on query params. Defaults to 0, 50 func getStartLimit(req *http.Request) (start int, limit int) { start = 0 diff --git a/server/apiv1/send.go b/server/apiv1/send.go new file mode 100644 index 0000000..7733380 --- /dev/null +++ b/server/apiv1/send.go @@ -0,0 +1,275 @@ +package apiv1 + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/mail" + "strings" + + "github.com/axllent/mailpit/internal/tools" + "github.com/axllent/mailpit/server/smtpd" + "github.com/jhillyerd/enmime" +) + +// swagger:parameters SendMessage +type sendMessageParams struct { + // in: body + Body *SendRequest +} + +// SendRequest to send a message via HTTP +// swagger:model SendRequest +type SendRequest struct { + // "From" recipient + // required: true + From struct { + // Optional name + // example: John Doe + Name string + // Email address + // example: john@example.com + // required: true + Email string + } + + // "To" recipients + To []struct { + // Optional name + // example: Jane Doe + Name string + // Email address + // example: jane@example.com + // required: true + Email string + } + + // Cc recipients + Cc []struct { + // Optional name + // example: Manager + Name string + // Email address + // example: manager@example.com + // required: true + Email string + } + + // Bcc recipients email addresses only + // example: ["jack@example.com"] + Bcc []string + + // Optional Reply-To recipients + ReplyTo []struct { + // Optional name + // example: Secretary + Name string + // Email address + // example: secretary@example.com + // required: true + Email string + } + + // Subject + // example: Mailpit message via the HTTP API + Subject string + + // Message body (text) + // example: This is the text body + Text string + + // Message body (HTML) + // example:

Mailpit is awesome!

+ HTML string + + // Attachments + Attachments []struct { + // Base64-encoded string of the file content + // required: true + // example: VGhpcyBpcyBhIHBsYWluIHRleHQgYXR0YWNobWVudA== + Content string + // Filename + // required: true + // example: AttachedFile.txt + Filename string + } + + // Mailpit tags + // example: ["Tag 1","Tag 2"] + Tags []string + + // Optional headers in {"key":"value"} format + // example: {"X-IP":"1.2.3.4"} + Headers map[string]string +} + +// SendMessageConfirmation struct +type SendMessageConfirmation struct { + // Database ID + // example: iAfZVVe2UQFNSG5BAjgYwa + ID string +} + +// JSONErrorMessage struct +type JSONErrorMessage struct { + // Error message + // example: invalid format + Error string +} + +// SendMessageHandler handles HTTP requests to send a new message +func SendMessageHandler(w http.ResponseWriter, r *http.Request) { + // swagger:route POST /api/v1/send message SendMessage + // + // # Send a message + // + // Send a message via the HTTP API. + // + // Consumes: + // - application/json + // + // Produces: + // - application/json + // + // Schemes: http, https + // + // Responses: + // 200: sendMessageResponse + // default: jsonErrorResponse + + decoder := json.NewDecoder(r.Body) + + data := SendRequest{} + + if err := decoder.Decode(&data); err != nil { + httpJSONError(w, err.Error()) + return + } + + id, err := data.Send(r.RemoteAddr) + + if err != nil { + httpJSONError(w, err.Error()) + return + } + + bytes, _ := json.Marshal(SendMessageConfirmation{ID: id}) + + w.Header().Add("Content-Type", "application/json") + _, _ = w.Write(bytes) +} + +// Send will validate the message structure and attempt to send to Mailpit. +// It returns a sending summary or an error. +func (d SendRequest) Send(remoteAddr string) (string, error) { + ip, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + return "", fmt.Errorf("error parsing request RemoteAddr: %s", err.Error()) + } + + ipAddr := &net.IPAddr{IP: net.ParseIP(ip)} + + addresses := []string{} + + msg := enmime.Builder(). + From(d.From.Name, d.From.Email). + Subject(d.Subject). + Text([]byte(d.Text)) + + if d.HTML != "" { + msg = msg.HTML([]byte(d.HTML)) + } + + if len(d.To) > 0 { + for _, a := range d.To { + if _, err := mail.ParseAddress(a.Email); err == nil { + msg = msg.To(a.Name, a.Email) + addresses = append(addresses, a.Email) + } else { + return "", fmt.Errorf("invalid To address: %s", a.Email) + } + } + } + + if len(d.Cc) > 0 { + for _, a := range d.Cc { + if _, err := mail.ParseAddress(a.Email); err == nil { + msg = msg.CC(a.Name, a.Email) + addresses = append(addresses, a.Email) + } else { + return "", fmt.Errorf("invalid Cc address: %s", a.Email) + } + } + } + + if len(d.Bcc) > 0 { + for _, e := range d.Bcc { + if _, err := mail.ParseAddress(e); err == nil { + msg = msg.BCC("", e) + addresses = append(addresses, e) + } else { + return "", fmt.Errorf("invalid Bcc address: %s", e) + } + } + } + + if len(d.ReplyTo) > 0 { + for _, a := range d.ReplyTo { + if _, err := mail.ParseAddress(a.Email); err == nil { + msg = msg.ReplyTo(a.Name, a.Email) + } else { + return "", fmt.Errorf("invalid Reply-To address: %s", a.Email) + } + } + } + + restrictedHeaders := []string{"To", "From", "Cc", "Bcc", "Reply-To", "Date", "Subject", "Content-Type", "Mime-Version"} + + if len(d.Tags) > 0 { + msg = msg.Header("X-Tags", strings.Join(d.Tags, ", ")) + restrictedHeaders = append(restrictedHeaders, "X-Tags") + } + + if len(d.Headers) > 0 { + for k, v := range d.Headers { + // check header isn't in "restricted" headers + if tools.InArray(k, restrictedHeaders) { + return "", fmt.Errorf("cannot overwrite header: \"%s\"", k) + } + msg = msg.Header(k, v) + } + } + + if len(d.Attachments) > 0 { + for _, a := range d.Attachments { + // workaround: split string because JS readAsDataURL() returns the base64 string + // with the mime type prefix eg: data:image/png;base64, + parts := strings.Split(a.Content, ",") + content := parts[len(parts)-1] + b, err := base64.StdEncoding.DecodeString(content) + if err != nil { + return "", fmt.Errorf("error decoding base64 attachment \"%s\": %s", a.Filename, err.Error()) + } + + mimeType := http.DetectContentType(b) + msg = msg.AddAttachment(b, mimeType, a.Filename) + } + } + + part, err := msg.Build() + if err != nil { + return "", fmt.Errorf("error building message: %s", err.Error()) + } + + var buff bytes.Buffer + + if err := part.Encode(io.Writer(&buff)); err != nil { + return "", fmt.Errorf("error building message: %s", err.Error()) + } + + return smtpd.Store(ipAddr, d.From.Email, addresses, buff.Bytes()) +} diff --git a/server/apiv1/swagger.go b/server/apiv1/swagger.go index 8dffcf0..96bfe81 100644 --- a/server/apiv1/swagger.go +++ b/server/apiv1/swagger.go @@ -170,6 +170,7 @@ type htmlResponse string // HTTP error response will return with a >= 400 response code // swagger:response ErrorResponse +// example: invalid request type errorResponse string // Plain text "ok" response @@ -179,3 +180,21 @@ type okResponse string // Plain JSON array response // swagger:response ArrayResponse type arrayResponse []string + +// Confirmation message for HTTP send API +// swagger:response sendMessageResponse +type sendMessageResponse struct { + // Response for sending messages via the HTTP API + // + // in: body + Body SendMessageConfirmation +} + +// JSON error response +// swagger:response jsonErrorResponse +type jsonErrorResponse struct { + // A JSON-encoded error response + // + // in: body + Body JSONErrorMessage +} diff --git a/server/server.go b/server/server.go index f659165..bc0e058 100644 --- a/server/server.go +++ b/server/server.go @@ -127,10 +127,11 @@ func apiRoutes() *mux.Router { r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.GetMessages)).Methods("GET") r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.SetReadStatus)).Methods("PUT") r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE") - r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.GetAllTags)).Methods("GET") - r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetMessageTags)).Methods("PUT") r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.Search)).Methods("GET") r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.DeleteSearch)).Methods("DELETE") + r.HandleFunc(config.Webroot+"api/v1/send", middleWareFunc(apiv1.SendMessageHandler)).Methods("POST") + r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.GetAllTags)).Methods("GET") + r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetMessageTags)).Methods("PUT") r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}", middleWareFunc(apiv1.DownloadAttachment)).Methods("GET") r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail)).Methods("GET") r.HandleFunc(config.Webroot+"api/v1/message/{id}/headers", middleWareFunc(apiv1.GetHeaders)).Methods("GET") diff --git a/server/server_test.go b/server/server_test.go index 206a1c4..3646e31 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -202,6 +202,106 @@ func TestAPIv1Search(t *testing.T) { assertSearchEqual(t, ts.URL+"/api/v1/search", "!tag:\"Test tag 023\"", 99) } +func TestAPIv1Send(t *testing.T) { + setup() + defer storage.Close() + + r := apiRoutes() + + ts := httptest.NewServer(r) + defer ts.Close() + + jsonData := `{ + "From": { + "Email": "john@example.com", + "Name": "John Doe" + }, + "To": [ + { + "Email": "jane@example.com", + "Name": "Jane Doe" + } + ], + "Cc": [ + { + "Email": "manager1@example.com", + "Name": "Manager 1" + }, + { + "Email": "manager2@example.com", + "Name": "Manager 2" + } + ], + "Bcc": ["jack@example.com"], + "Headers": { + "X-IP": "1.2.3.4" + }, + "Subject": "Mailpit message via the HTTP API", + "Text": "This is the text body", + "HTML": "

Mailpit is awesome!

", + "Attachments": [ + { + "Content": "VGhpcyBpcyBhIHBsYWluIHRleHQgYXR0YWNobWVudA==", + "Filename": "Attached File.txt" + } + ], + "ReplyTo": [ + { + "Email": "secretary@example.com", + "Name": "Secretary" + } + ], + "Tags": [ + "Tag 1", + "Tag 2" + ] + }` + + t.Log("Sending message via HTTP API") + b, err := clientPost(ts.URL+"/api/v1/send", jsonData) + if err != nil { + t.Errorf("Expected nil, received %s", err.Error()) + } + + resp := apiv1.SendMessageConfirmation{} + + if err := json.Unmarshal(b, &resp); err != nil { + t.Errorf(err.Error()) + return + } + + t.Logf("Fetching response for message %s", resp.ID) + msg, err := fetchMessage(ts.URL + "/api/v1/message/" + resp.ID) + if err != nil { + t.Errorf(err.Error()) + } + + t.Logf("Testing response for message %s", resp.ID) + assertEqual(t, `Mailpit message via the HTTP API`, msg.Subject, "wrong subject") + assertEqual(t, `This is the text body`, msg.Text, "wrong text") + assertEqual(t, `

Mailpit is awesome!

`, msg.HTML, "wrong HTML") + assertEqual(t, `"John Doe" `, msg.From.String(), "wrong HTML") + assertEqual(t, 1, len(msg.To), "wrong To count") + assertEqual(t, `"Jane Doe" `, msg.To[0].String(), "wrong To address") + assertEqual(t, 2, len(msg.Cc), "wrong Cc count") + assertEqual(t, `"Manager 1" `, msg.Cc[0].String(), "wrong Cc address") + assertEqual(t, `"Manager 2" `, msg.Cc[1].String(), "wrong Cc address") + assertEqual(t, 1, len(msg.Bcc), "wrong Bcc count") + assertEqual(t, ``, msg.Bcc[0].String(), "wrong Bcc address") + assertEqual(t, 1, len(msg.ReplyTo), "wrong Reply-To count") + assertEqual(t, `"Secretary" `, msg.ReplyTo[0].String(), "wrong Reply-To address") + assertEqual(t, 2, len(msg.Tags), "wrong Tags count") + assertEqual(t, `Tag 1,Tag 2`, strings.Join(msg.Tags, ","), "wrong Tags") + assertEqual(t, 1, len(msg.Attachments), "wrong Attachment count") + assertEqual(t, `Attached File.txt`, msg.Attachments[0].FileName, "wrong Attachment name") + + attachmentBytes, err := clientGet(ts.URL + "/api/v1/message/" + resp.ID + "/part/" + msg.Attachments[0].PartID) + if err != nil { + t.Errorf(err.Error()) + } + assertEqual(t, `This is a plain text attachment`, string(attachmentBytes), "wrong Attachment content") +} + func setup() { logger.NoLogging = true config.MaxMessages = 0 @@ -288,7 +388,21 @@ func insertEmailData(t *testing.T) { t.Fail() } } +} +func fetchMessage(url string) (storage.Message, error) { + m := storage.Message{} + + data, err := clientGet(url) + if err != nil { + return m, err + } + + if err := json.Unmarshal(data, &m); err != nil { + return m, err + } + + return m, nil } func fetchMessages(url string) (apiv1.MessagesSummary, error) { @@ -372,6 +486,31 @@ func clientPut(url, body string) ([]byte, error) { return data, err } +func clientPost(url, body string) ([]byte, error) { + client := new(http.Client) + + b := strings.NewReader(body) + req, err := http.NewRequest("POST", url, b) + if err != nil { + return nil, err + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + + return data, err +} + func assertEqual(t *testing.T, a interface{}, b interface{}, message string) { if a == b { return diff --git a/server/smtpd/smtpd.go b/server/smtpd/smtpd.go index 169b5e5..95580df 100644 --- a/server/smtpd/smtpd.go +++ b/server/smtpd/smtpd.go @@ -23,7 +23,15 @@ var ( DisableReverseDNS bool ) +// MailHandler handles the incoming message to store in the database func mailHandler(origin net.Addr, from string, to []string, data []byte) error { + _, err := Store(origin, from, to, data) + + return err +} + +// Store will attempt to save a message to the database +func Store(origin net.Addr, from string, to []string, data []byte) (string, error) { if !config.SMTPStrictRFCHeaders { // replace all (\r\r\n) with (\r\n) // @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153 @@ -34,7 +42,7 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error { if err != nil { logger.Log().Errorf("[smtpd] error parsing message: %s", err.Error()) stats.LogSMTPRejected() - return err + return "", err } // check / set the Return-Path based on SMTP from @@ -70,7 +78,7 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error { if storage.MessageIDExists(messageID) { logger.Log().Debugf("[smtpd] duplicate message found, ignoring %s", messageID) stats.LogSMTPIgnored() - return nil + return "", nil } } @@ -117,10 +125,10 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error { logger.Log().Debugf("[smtpd] added missing addresses to Bcc header: %s", strings.Join(missingAddresses, ", ")) } - _, err = storage.Store(&data) + id, err := storage.Store(&data) if err != nil { logger.Log().Errorf("[db] error storing message: %s", err.Error()) - return err + return "", err } stats.LogSMTPAccepted(len(data)) @@ -130,7 +138,7 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error { subject := msg.Header.Get("Subject") logger.Log().Debugf("[smtpd] received (%s) from:%s subject:%q", cleanIP(origin), from, subject) - return nil + return id, err } func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, _ []byte) (bool, error) { diff --git a/server/ui/api/v1/swagger.json b/server/ui/api/v1/swagger.json index 984850e..7a461a4 100644 --- a/server/ui/api/v1/swagger.json +++ b/server/ui/api/v1/swagger.json @@ -606,6 +606,43 @@ } } }, + "/api/v1/send": { + "post": { + "description": "Send a message via the HTTP API.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "schemes": [ + "http", + "https" + ], + "tags": [ + "message" + ], + "summary": "Send a message", + "operationId": "SendMessage", + "parameters": [ + { + "name": "Body", + "in": "body", + "schema": { + "$ref": "#/definitions/SendRequest" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/sendMessageResponse" + }, + "default": { + "$ref": "#/responses/jsonErrorResponse" + } + } + } + }, "/api/v1/tags": { "get": { "description": "Returns a JSON array of all unique message tags.", @@ -1083,6 +1120,18 @@ "x-go-name": "Warning", "x-go-package": "github.com/axllent/mailpit/internal/htmlcheck" }, + "JSONErrorMessage": { + "description": "JSONErrorMessage struct", + "type": "object", + "properties": { + "Error": { + "description": "Error message", + "type": "string", + "example": "invalid format" + } + }, + "x-go-package": "github.com/axllent/mailpit/server/apiv1" + }, "Link": { "description": "Link struct", "type": "object", @@ -1375,6 +1424,182 @@ }, "x-go-package": "github.com/axllent/mailpit/internal/spamassassin" }, + "SendMessageConfirmation": { + "description": "SendMessageConfirmation struct", + "type": "object", + "properties": { + "ID": { + "description": "Database ID", + "type": "string", + "example": "iAfZVVe2UQFNSG5BAjgYwa" + } + }, + "x-go-package": "github.com/axllent/mailpit/server/apiv1" + }, + "SendRequest": { + "description": "SendRequest to send a message via HTTP", + "type": "object", + "required": [ + "From" + ], + "properties": { + "Attachments": { + "description": "Attachments", + "type": "array", + "items": { + "type": "object", + "required": [ + "Content", + "Filename" + ], + "properties": { + "Content": { + "description": "Base64-encoded string of the file content", + "type": "string", + "example": "VGhpcyBpcyBhIHBsYWluIHRleHQgYXR0YWNobWVudA==" + }, + "Filename": { + "description": "Filename", + "type": "string", + "example": "AttachedFile.txt" + } + } + } + }, + "Bcc": { + "description": "Bcc recipients email addresses only", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "jack@example.com" + ] + }, + "Cc": { + "description": "Cc recipients", + "type": "array", + "items": { + "type": "object", + "required": [ + "Email" + ], + "properties": { + "Email": { + "description": "Email address", + "type": "string", + "example": "manager@example.com" + }, + "Name": { + "description": "Optional name", + "type": "string", + "example": "Manager" + } + } + } + }, + "From": { + "description": "\"From\" recipient", + "type": "object", + "required": [ + "Email" + ], + "properties": { + "Email": { + "description": "Email address", + "type": "string", + "example": "john@example.com" + }, + "Name": { + "description": "Optional name", + "type": "string", + "example": "John Doe" + } + } + }, + "HTML": { + "description": "Message body (HTML)", + "type": "string", + "example": "\u003cp style=\"font-family: arial\"\u003eMailpit is \u003cb\u003eawesome\u003c/b\u003e!\u003c/p\u003e" + }, + "Headers": { + "description": "Optional headers in {\"key\":\"value\"} format", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "example": { + "X-IP": "1.2.3.4" + } + }, + "ReplyTo": { + "description": "Optional Reply-To recipients", + "type": "array", + "items": { + "type": "object", + "required": [ + "Email" + ], + "properties": { + "Email": { + "description": "Email address", + "type": "string", + "example": "secretary@example.com" + }, + "Name": { + "description": "Optional name", + "type": "string", + "example": "Secretary" + } + } + } + }, + "Subject": { + "description": "Subject", + "type": "string", + "example": "Mailpit message via the HTTP API" + }, + "Tags": { + "description": "Mailpit tags", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "Tag 1", + "Tag 2" + ] + }, + "Text": { + "description": "Message body (text)", + "type": "string", + "example": "This is the text body" + }, + "To": { + "description": "\"To\" recipients", + "type": "array", + "items": { + "type": "object", + "required": [ + "Email" + ], + "properties": { + "Email": { + "description": "Email address", + "type": "string", + "example": "jane@example.com" + }, + "Name": { + "description": "Optional name", + "type": "string", + "example": "Jane Doe" + } + } + } + } + }, + "x-go-package": "github.com/axllent/mailpit/server/apiv1" + }, "SpamAssassinResponse": { "description": "Result is a SpamAssassin result", "type": "object", @@ -1582,6 +1807,18 @@ "schema": { "$ref": "#/definitions/WebUIConfiguration" } + }, + "jsonErrorResponse": { + "description": "JSON error response", + "schema": { + "$ref": "#/definitions/JSONErrorMessage" + } + }, + "sendMessageResponse": { + "description": "Confirmation message for HTTP send API", + "schema": { + "$ref": "#/definitions/SendMessageConfirmation" + } } } } \ No newline at end of file