Skip to content

Commit

Permalink
Feature: API endpoint for sending (#278)
Browse files Browse the repository at this point in the history
  • Loading branch information
axllent committed May 3, 2024
1 parent fce4865 commit a15f032
Show file tree
Hide file tree
Showing 8 changed files with 715 additions and 8 deletions.
17 changes: 16 additions & 1 deletion internal/tools/utils.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
}
13 changes: 13 additions & 0 deletions server/apiv1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
275 changes: 275 additions & 0 deletions server/apiv1/send.go
Original file line number Diff line number Diff line change
@@ -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: [email protected]
// required: true
Email string
}

// "To" recipients
To []struct {
// Optional name
// example: Jane Doe
Name string
// Email address
// example: [email protected]
// required: true
Email string
}

// Cc recipients
Cc []struct {
// Optional name
// example: Manager
Name string
// Email address
// example: [email protected]
// required: true
Email string
}

// Bcc recipients email addresses only
// example: ["[email protected]"]
Bcc []string

// Optional Reply-To recipients
ReplyTo []struct {
// Optional name
// example: Secretary
Name string
// Email address
// example: [email protected]
// 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: <p style="font-family: arial">Mailpit is <b>awesome</b>!</p>
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,<base64String>
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())
}
19 changes: 19 additions & 0 deletions server/apiv1/swagger.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
5 changes: 3 additions & 2 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading

0 comments on commit a15f032

Please sign in to comment.