From 657cada9169c3670a62eb6f872272d9ab8140460 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sat, 9 Nov 2024 12:33:16 +1300 Subject: [PATCH] Chore: Add swagger examples & API code restructure --- server/apiv1/api.go | 676 ---------------------- server/apiv1/{webui.go => application.go} | 49 +- server/apiv1/info.go | 31 - server/apiv1/message.go | 252 ++++++++ server/apiv1/messages.go | 388 +++++++++++++ server/apiv1/other.go | 238 ++++++++ server/apiv1/release.go | 37 +- server/apiv1/send.go | 35 +- server/apiv1/structs.go | 39 -- server/apiv1/swagger.go | 188 +----- server/apiv1/tags.go | 98 +++- server/apiv1/testing.go | 159 +++++ server/apiv1/thumbnails.go | 40 +- server/handlers/messages.go | 142 ----- server/server.go | 4 +- server/ui/api/v1/swagger.json | 447 +++++++------- 16 files changed, 1479 insertions(+), 1344 deletions(-) rename server/apiv1/{webui.go => application.go} (66%) delete mode 100644 server/apiv1/info.go create mode 100644 server/apiv1/message.go create mode 100644 server/apiv1/messages.go create mode 100644 server/apiv1/other.go create mode 100644 server/apiv1/testing.go diff --git a/server/apiv1/api.go b/server/apiv1/api.go index bdcbebb0f8..e51c54cbec 100644 --- a/server/apiv1/api.go +++ b/server/apiv1/api.go @@ -2,692 +2,16 @@ package apiv1 import ( - "bytes" "encoding/json" "fmt" "net/http" - "net/mail" "strconv" - "strings" "github.com/araddon/dateparse" "github.com/axllent/mailpit/config" - "github.com/axllent/mailpit/internal/htmlcheck" - "github.com/axllent/mailpit/internal/linkcheck" "github.com/axllent/mailpit/internal/logger" - "github.com/axllent/mailpit/internal/spamassassin" - "github.com/axllent/mailpit/internal/storage" - "github.com/gorilla/mux" - "github.com/jhillyerd/enmime" ) -// GetMessages returns a paginated list of messages as JSON -func GetMessages(w http.ResponseWriter, r *http.Request) { - // swagger:route GET /api/v1/messages messages GetMessages - // - // # List messages - // - // Returns messages from the mailbox ordered from newest to oldest. - // - // Produces: - // - application/json - // - // Schemes: http, https - // - // Parameters: - // + name: start - // in: query - // description: Pagination offset - // required: false - // type: integer - // default: 0 - // + name: limit - // in: query - // description: Limit results - // required: false - // type: integer - // default: 50 - // - // Responses: - // 200: MessagesSummaryResponse - // default: ErrorResponse - start, beforeTS, limit := getStartLimit(r) - - messages, err := storage.List(start, beforeTS, limit) - if err != nil { - httpError(w, err.Error()) - return - } - - stats := storage.StatsGet() - - var res MessagesSummary - - res.Start = start - res.Messages = messages - res.Count = float64(len(messages)) // legacy - now undocumented in API specs - res.Total = stats.Total - res.Unread = stats.Unread - res.Tags = stats.Tags - res.MessagesCount = stats.Total - - w.Header().Add("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(res); err != nil { - httpError(w, err.Error()) - } -} - -// Search returns the latest messages as JSON -func Search(w http.ResponseWriter, r *http.Request) { - // swagger:route GET /api/v1/search messages MessagesSummary - // - // # Search messages - // - // Returns messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/), sorted by received date (descending). - // - // Produces: - // - application/json - // - // Schemes: http, https - // - // Parameters: - // + name: query - // in: query - // description: Search query - // required: true - // type: string - // + name: start - // in: query - // description: Pagination offset - // required: false - // type: integer - // default: 0 - // + name: limit - // in: query - // description: Limit results - // required: false - // type: integer - // default: 50 - // + name: tz - // in: query - // description: [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used specifically for `before:` & `after:` searches (eg: "Pacific/Auckland"). - // required: false - // type: string - // - // Responses: - // 200: MessagesSummaryResponse - // default: ErrorResponse - search := strings.TrimSpace(r.URL.Query().Get("query")) - if search == "" { - httpError(w, "Error: no search query") - return - } - - start, beforeTS, limit := getStartLimit(r) - - messages, results, err := storage.Search(search, r.URL.Query().Get("tz"), start, beforeTS, limit) - if err != nil { - httpError(w, err.Error()) - return - } - - stats := storage.StatsGet() - - var res MessagesSummary - - res.Start = start - res.Messages = messages - res.Count = float64(len(messages)) // legacy - now undocumented in API specs - res.Total = stats.Total // total messages in mailbox - res.MessagesCount = float64(results) - res.Unread = stats.Unread - res.Tags = stats.Tags - - w.Header().Add("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(res); err != nil { - httpError(w, err.Error()) - } -} - -// DeleteSearch will delete all messages matching a search -func DeleteSearch(w http.ResponseWriter, r *http.Request) { - // swagger:route DELETE /api/v1/search messages DeleteSearch - // - // # Delete messages by search - // - // Delete all messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/). - // - // Produces: - // - application/json - // - // Schemes: http, https - // - // Parameters: - // + name: query - // in: query - // description: Search query - // required: true - // type: string - // + name: tz - // in: query - // description: [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used specifically for `before:` & `after:` searches (eg: "Pacific/Auckland"). - // required: false - // type: string - // - // Responses: - // 200: OKResponse - // default: ErrorResponse - search := strings.TrimSpace(r.URL.Query().Get("query")) - if search == "" { - httpError(w, "Error: no search query") - return - } - - if err := storage.DeleteSearch(search, r.URL.Query().Get("tz")); err != nil { - httpError(w, err.Error()) - return - } - - w.Header().Add("Content-Type", "text/plain") - _, _ = w.Write([]byte("ok")) -} - -// GetMessage (method: GET) returns the Message as JSON -func GetMessage(w http.ResponseWriter, r *http.Request) { - // swagger:route GET /api/v1/message/{ID} message Message - // - // # Get message summary - // - // Returns the summary of a message, marking the message as read. - // - // The ID can be set to `latest` to return the latest message. - // - // Produces: - // - application/json - // - // Schemes: http, https - // - // Parameters: - // + name: ID - // in: path - // description: Message database ID or "latest" - // required: true - // type: string - // - // Responses: - // 200: Message - // default: ErrorResponse - - vars := mux.Vars(r) - - id := vars["id"] - - if id == "latest" { - var err error - id, err = storage.LatestID(r) - if err != nil { - w.WriteHeader(404) - fmt.Fprint(w, err.Error()) - return - } - } - - msg, err := storage.GetMessage(id) - if err != nil { - fourOFour(w) - return - } - - w.Header().Add("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(msg); err != nil { - httpError(w, err.Error()) - } -} - -// DownloadAttachment (method: GET) returns the attachment data -func DownloadAttachment(w http.ResponseWriter, r *http.Request) { - // swagger:route GET /api/v1/message/{ID}/part/{PartID} message Attachment - // - // # Get message attachment - // - // This will return the attachment part using the appropriate Content-Type. - // - // Produces: - // - application/* - // - image/* - // - text/* - // - // Schemes: http, https - // - // Parameters: - // + name: ID - // in: path - // description: Message database ID - // required: true - // type: string - // + name: PartID - // in: path - // description: Attachment part ID - // required: true - // type: string - // - // Responses: - // 200: BinaryResponse - // default: ErrorResponse - - vars := mux.Vars(r) - - id := vars["id"] - partID := vars["partID"] - - a, err := storage.GetAttachmentPart(id, partID) - if err != nil { - fourOFour(w) - return - } - fileName := a.FileName - if fileName == "" { - fileName = a.ContentID - } - - w.Header().Add("Content-Type", a.ContentType) - w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"") - _, _ = w.Write(a.Content) -} - -// GetHeaders (method: GET) returns the message headers as JSON -func GetHeaders(w http.ResponseWriter, r *http.Request) { - // swagger:route GET /api/v1/message/{ID}/headers message Headers - // - // # Get message headers - // - // Returns the message headers as an array. - // - // The ID can be set to `latest` to return the latest message headers. - // - // Produces: - // - application/json - // - // Schemes: http, https - // - // Parameters: - // + name: ID - // in: path - // description: Message database ID or "latest" - // required: true - // type: string - // - // Responses: - // 200: MessageHeaders - // default: ErrorResponse - - vars := mux.Vars(r) - - id := vars["id"] - - if id == "latest" { - var err error - id, err = storage.LatestID(r) - if err != nil { - w.WriteHeader(404) - fmt.Fprint(w, err.Error()) - return - } - } - - data, err := storage.GetMessageRaw(id) - if err != nil { - fourOFour(w) - return - } - - reader := bytes.NewReader(data) - m, err := mail.ReadMessage(reader) - if err != nil { - httpError(w, err.Error()) - return - } - - w.Header().Add("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(m.Header); err != nil { - httpError(w, err.Error()) - } -} - -// DownloadRaw (method: GET) returns the full email source as plain text -func DownloadRaw(w http.ResponseWriter, r *http.Request) { - // swagger:route GET /api/v1/message/{ID}/raw message Raw - // - // # Get message source - // - // Returns the full email source as plain text. - // - // The ID can be set to `latest` to return the latest message source. - // - // Produces: - // - text/plain - // - // Schemes: http, https - // - // Parameters: - // + name: ID - // in: path - // description: Message database ID or "latest" - // required: true - // type: string - // - // Responses: - // 200: TextResponse - // default: ErrorResponse - - vars := mux.Vars(r) - - id := vars["id"] - dl := r.FormValue("dl") - - if id == "latest" { - var err error - id, err = storage.LatestID(r) - if err != nil { - w.WriteHeader(404) - fmt.Fprint(w, err.Error()) - return - } - } - - data, err := storage.GetMessageRaw(id) - if err != nil { - fourOFour(w) - return - } - - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - if dl == "1" { - w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"") - } - _, _ = w.Write(data) -} - -// DeleteMessages (method: DELETE) deletes all messages matching IDS. -func DeleteMessages(w http.ResponseWriter, r *http.Request) { - // swagger:route DELETE /api/v1/messages messages DeleteMessages - // - // # Delete messages - // - // Delete individual or all messages. If no IDs are provided then all messages are deleted. - // - // Consumes: - // - application/json - // - // Produces: - // - text/plain - // - // Schemes: http, https - // - // Responses: - // 200: OKResponse - // default: ErrorResponse - - decoder := json.NewDecoder(r.Body) - var data struct { - IDs []string - } - err := decoder.Decode(&data) - if err != nil || len(data.IDs) == 0 { - if err := storage.DeleteAllMessages(); err != nil { - httpError(w, err.Error()) - return - } - } else { - if err := storage.DeleteMessages(data.IDs); err != nil { - httpError(w, err.Error()) - return - } - } - - w.Header().Add("Content-Type", "text/plain") - _, _ = w.Write([]byte("ok")) -} - -// SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs -// If no IDs are provided then all messages are updated. -func SetReadStatus(w http.ResponseWriter, r *http.Request) { - // swagger:route PUT /api/v1/messages messages SetReadStatus - // - // # Set read status - // - // If no IDs are provided then all messages are updated. - // - // Consumes: - // - application/json - // - // Produces: - // - text/plain - // - // Schemes: http, https - // - // Responses: - // 200: OKResponse - // default: ErrorResponse - - decoder := json.NewDecoder(r.Body) - - var data struct { - Read bool - IDs []string - } - - err := decoder.Decode(&data) - if err != nil { - httpError(w, err.Error()) - return - } - - ids := data.IDs - - if len(ids) == 0 { - if data.Read { - err := storage.MarkAllRead() - if err != nil { - httpError(w, err.Error()) - return - } - } else { - err := storage.MarkAllUnread() - if err != nil { - httpError(w, err.Error()) - return - } - } - } else { - if data.Read { - for _, id := range ids { - if err := storage.MarkRead(id); err != nil { - httpError(w, err.Error()) - return - } - } - } else { - for _, id := range ids { - if err := storage.MarkUnread(id); err != nil { - httpError(w, err.Error()) - return - } - } - } - } - - w.Header().Add("Content-Type", "text/plain") - _, _ = w.Write([]byte("ok")) -} - -// HTMLCheck returns a summary of the HTML client support -func HTMLCheck(w http.ResponseWriter, r *http.Request) { - // swagger:route GET /api/v1/message/{ID}/html-check Other HTMLCheck - // - // # HTML check - // - // Returns the summary of the message HTML checker. - // - // Produces: - // - application/json - // - // Schemes: http, https - // - // Responses: - // 200: HTMLCheckResponse - // default: ErrorResponse - - vars := mux.Vars(r) - id := vars["id"] - - if id == "latest" { - var err error - id, err = storage.LatestID(r) - if err != nil { - w.WriteHeader(404) - fmt.Fprint(w, err.Error()) - return - } - } - - raw, err := storage.GetMessageRaw(id) - if err != nil { - fourOFour(w) - return - } - - e := bytes.NewReader(raw) - - parser := enmime.NewParser(enmime.DisableCharacterDetection(true)) - - msg, err := parser.ReadEnvelope(e) - if err != nil { - httpError(w, err.Error()) - return - } - - if msg.HTML == "" { - httpError(w, "message does not contain HTML") - return - } - - checks, err := htmlcheck.RunTests(msg.HTML) - if err != nil { - httpError(w, err.Error()) - return - } - - w.Header().Add("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(checks); err != nil { - httpError(w, err.Error()) - } -} - -// LinkCheck returns a summary of links in the email -func LinkCheck(w http.ResponseWriter, r *http.Request) { - // swagger:route GET /api/v1/message/{ID}/link-check Other LinkCheck - // - // # Link check - // - // Returns the summary of the message Link checker. - // - // Produces: - // - application/json - // - // Schemes: http, https - // - // Responses: - // 200: LinkCheckResponse - // default: ErrorResponse - - if config.DemoMode { - httpError(w, "this functionality has been disabled for demonstration purposes") - return - } - - vars := mux.Vars(r) - id := vars["id"] - - if id == "latest" { - var err error - id, err = storage.LatestID(r) - if err != nil { - w.WriteHeader(404) - fmt.Fprint(w, err.Error()) - return - } - } - - msg, err := storage.GetMessage(id) - if err != nil { - fourOFour(w) - return - } - - f := r.URL.Query().Get("follow") - followRedirects := f == "true" || f == "1" - - summary, err := linkcheck.RunTests(msg, followRedirects) - if err != nil { - httpError(w, err.Error()) - return - } - - w.Header().Add("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(summary); err != nil { - httpError(w, err.Error()) - } -} - -// SpamAssassinCheck returns a summary of SpamAssassin results (if enabled) -func SpamAssassinCheck(w http.ResponseWriter, r *http.Request) { - // swagger:route GET /api/v1/message/{ID}/sa-check Other SpamAssassinCheck - // - // # SpamAssassin check - // - // Returns the SpamAssassin summary (if enabled) of the message. - // - // Produces: - // - application/json - // - // Schemes: http, https - // - // Responses: - // 200: SpamAssassinResponse - // default: ErrorResponse - - vars := mux.Vars(r) - id := vars["id"] - - if id == "latest" { - var err error - id, err = storage.LatestID(r) - if err != nil { - w.WriteHeader(404) - fmt.Fprint(w, err.Error()) - return - } - } - - msg, err := storage.GetMessageRaw(id) - if err != nil { - fourOFour(w) - return - } - - summary, err := spamassassin.Check(msg) - if err != nil { - httpError(w, err.Error()) - return - } - - w.Header().Add("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(summary); err != nil { - httpError(w, err.Error()) - } -} - // FourOFour returns a basic 404 message func fourOFour(w http.ResponseWriter) { w.Header().Set("Referrer-Policy", "no-referrer") diff --git a/server/apiv1/webui.go b/server/apiv1/application.go similarity index 66% rename from server/apiv1/webui.go rename to server/apiv1/application.go index e506f59d58..35178ae4e7 100644 --- a/server/apiv1/webui.go +++ b/server/apiv1/application.go @@ -6,8 +6,41 @@ import ( "net/http" "github.com/axllent/mailpit/config" + "github.com/axllent/mailpit/internal/stats" ) +// Application information +// swagger:response AppInfoResponse +type appInfoResponse struct { + // Application information + // + // in: body + Body stats.AppInformation +} + +// AppInfo returns some basic details about the running app, and latest release. +func AppInfo(w http.ResponseWriter, _ *http.Request) { + // swagger:route GET /api/v1/info application AppInformation + // + // # Get application information + // + // Returns basic runtime information, message totals and latest release version. + // + // Produces: + // - application/json + // + // Schemes: http, https + // + // Responses: + // 200: AppInfoResponse + // 400: ErrorResponse + + w.Header().Add("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(stats.Load()); err != nil { + httpError(w, err.Error()) + } +} + // Response includes global web UI settings // // swagger:model WebUIConfiguration @@ -38,6 +71,15 @@ type webUIConfiguration struct { DuplicatesIgnored bool } +// Web UI configuration response +// swagger:response WebUIConfigurationResponse +type webUIConfigurationResponse struct { + // Web UI configuration settings + // + // in: body + Body webUIConfiguration +} + // WebUIConfig returns configuration settings for the web UI. func WebUIConfig(w http.ResponseWriter, _ *http.Request) { // swagger:route GET /api/v1/webui application WebUIConfiguration @@ -48,13 +90,14 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) { // Intended for web UI only! // // Produces: - // - application/json + // - application/json // // Schemes: http, https // // Responses: - // 200: WebUIConfigurationResponse - // default: ErrorResponse + // 200: WebUIConfigurationResponse + // 400: ErrorResponse + conf := webUIConfiguration{} conf.Label = config.Label diff --git a/server/apiv1/info.go b/server/apiv1/info.go deleted file mode 100644 index 579feeb285..0000000000 --- a/server/apiv1/info.go +++ /dev/null @@ -1,31 +0,0 @@ -package apiv1 - -import ( - "encoding/json" - "net/http" - - "github.com/axllent/mailpit/internal/stats" -) - -// AppInfo returns some basic details about the running app, and latest release. -func AppInfo(w http.ResponseWriter, _ *http.Request) { - // swagger:route GET /api/v1/info application AppInformation - // - // # Get application information - // - // Returns basic runtime information, message totals and latest release version. - // - // Produces: - // - application/json - // - // Schemes: http, https - // - // Responses: - // 200: InfoResponse - // default: ErrorResponse - - w.Header().Add("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(stats.Load()); err != nil { - httpError(w, err.Error()) - } -} diff --git a/server/apiv1/message.go b/server/apiv1/message.go new file mode 100644 index 0000000000..a8737c4028 --- /dev/null +++ b/server/apiv1/message.go @@ -0,0 +1,252 @@ +package apiv1 + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/mail" + + "github.com/axllent/mailpit/internal/storage" + "github.com/gorilla/mux" +) + +// swagger:parameters GetMessageParams +type getMessageParams struct { + // Message database ID or "latest" + // + // in: path + // required: true + // example: 4oRBnPtCXgAqZniRhzLNmS + ID string +} + +// GetMessage (method: GET) returns the Message as JSON +func GetMessage(w http.ResponseWriter, r *http.Request) { + // swagger:route GET /api/v1/message/{ID} message GetMessageParams + // + // # Get message summary + // + // Returns the summary of a message, marking the message as read. + // + // The ID can be set to `latest` to return the latest message. + // + // Produces: + // - application/json + // + // Schemes: http, https + // + // Responses: + // 200: Message + // 400: ErrorResponse + // 404: NotFoundResponse + + vars := mux.Vars(r) + + id := vars["id"] + + if id == "latest" { + var err error + id, err = storage.LatestID(r) + if err != nil { + w.WriteHeader(404) + fmt.Fprint(w, err.Error()) + return + } + } + + msg, err := storage.GetMessage(id) + if err != nil { + fourOFour(w) + return + } + + w.Header().Add("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(msg); err != nil { + httpError(w, err.Error()) + } +} + +// swagger:parameters GetHeadersParams +type getHeadersParams struct { + // Message database ID or "latest" + // + // in: path + // required: true + // example: 4oRBnPtCXgAqZniRhzLNmS + ID string +} + +// Message headers +// swagger:model MessageHeadersResponse +type messageHeaders map[string][]string + +// GetHeaders (method: GET) returns the message headers as JSON +func GetHeaders(w http.ResponseWriter, r *http.Request) { + // swagger:route GET /api/v1/message/{ID}/headers message GetHeadersParams + // + // # Get message headers + // + // Returns the message headers as an array. Note that header keys are returned alphabetically. + // + // The ID can be set to `latest` to return the latest message headers. + // + // Produces: + // - application/json + // + // Schemes: http, https + // + // Responses: + // 200: MessageHeadersResponse + // 400: ErrorResponse + // 404: NotFoundResponse + + vars := mux.Vars(r) + + id := vars["id"] + + if id == "latest" { + var err error + id, err = storage.LatestID(r) + if err != nil { + w.WriteHeader(404) + fmt.Fprint(w, err.Error()) + return + } + } + + data, err := storage.GetMessageRaw(id) + if err != nil { + fourOFour(w) + return + } + + reader := bytes.NewReader(data) + m, err := mail.ReadMessage(reader) + if err != nil { + httpError(w, err.Error()) + return + } + + w.Header().Add("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(m.Header); err != nil { + httpError(w, err.Error()) + } +} + +// swagger:parameters AttachmentParams +type attachmentParams struct { + // Message database ID or "latest" + // + // in: path + // required: true + // example: 4oRBnPtCXgAqZniRhzLNmS + ID string + + // Attachment part ID + // + // in: path + // required: true + // example: 2 + PartID string +} + +// DownloadAttachment (method: GET) returns the attachment data +func DownloadAttachment(w http.ResponseWriter, r *http.Request) { + // swagger:route GET /api/v1/message/{ID}/part/{PartID} message AttachmentParams + // + // # Get message attachment + // + // This will return the attachment part using the appropriate Content-Type. + // + // The ID can be set to `latest` to reference the latest message. + // + // Produces: + // - application/* + // - image/* + // - text/* + // + // Schemes: http, https + // + // Responses: + // 200: BinaryResponse + // 400: ErrorResponse + // 404: NotFoundResponse + + vars := mux.Vars(r) + + id := vars["id"] + partID := vars["partID"] + + a, err := storage.GetAttachmentPart(id, partID) + if err != nil { + fourOFour(w) + return + } + fileName := a.FileName + if fileName == "" { + fileName = a.ContentID + } + + w.Header().Add("Content-Type", a.ContentType) + w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"") + _, _ = w.Write(a.Content) +} + +// swagger:parameters DownloadRawParams +type downloadRawParams struct { + // Message database ID or "latest" + // + // in: path + // required: true + // example: 4oRBnPtCXgAqZniRhzLNmS + ID string +} + +// DownloadRaw (method: GET) returns the full email source as plain text +func DownloadRaw(w http.ResponseWriter, r *http.Request) { + // swagger:route GET /api/v1/message/{ID}/raw message DownloadRawParams + // + // # Get message source + // + // Returns the full email source as plain text. + // + // The ID can be set to `latest` to return the latest message source. + // + // Produces: + // - text/plain + // + // Schemes: http, https + // + // Responses: + // 200: TextResponse + // 400: ErrorResponse + // 404: NotFoundResponse + + vars := mux.Vars(r) + + id := vars["id"] + dl := r.FormValue("dl") + + if id == "latest" { + var err error + id, err = storage.LatestID(r) + if err != nil { + w.WriteHeader(404) + fmt.Fprint(w, err.Error()) + return + } + } + + data, err := storage.GetMessageRaw(id) + if err != nil { + fourOFour(w) + return + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + if dl == "1" { + w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"") + } + _, _ = w.Write(data) +} diff --git a/server/apiv1/messages.go b/server/apiv1/messages.go new file mode 100644 index 0000000000..a837cbd6c5 --- /dev/null +++ b/server/apiv1/messages.go @@ -0,0 +1,388 @@ +package apiv1 + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/axllent/mailpit/internal/storage" +) + +// swagger:parameters GetMessagesParams +type getMessagesParams struct { + // Pagination offset + // + // in: query + // name: start + // required: false + // default: 0 + // type: integer + // example: 100 + Start int `json:"start"` + + // Limit number of results + // + // in: query + // name: limit + // required: false + // default: 50 + // type: integer + // example: 50 + Limit int `json:"limit"` +} + +// Summary of messages +// swagger:response MessagesSummaryResponse +type messagesSummaryResponse struct { + // The messages summary + // in: body + Body MessagesSummary +} + +// MessagesSummary is a summary of a list of messages +type MessagesSummary struct { + // Total number of messages in mailbox + Total float64 `json:"total"` + + // Total number of unread messages in mailbox + Unread float64 `json:"unread"` + + // Legacy - now undocumented in API specs but left for backwards compatibility. + // Removed from API documentation 2023-07-12 + // swagger:ignore + Count float64 `json:"count"` + + // Total number of messages matching current query + MessagesCount float64 `json:"messages_count"` + + // Pagination offset + Start int `json:"start"` + + // All current tags + Tags []string `json:"tags"` + + // Messages summary + // in: body + Messages []storage.MessageSummary `json:"messages"` +} + +// GetMessages returns a paginated list of messages as JSON +func GetMessages(w http.ResponseWriter, r *http.Request) { + // swagger:route GET /api/v1/messages messages GetMessagesParams + // + // # List messages + // + // Returns messages from the mailbox ordered from newest to oldest. + // + // Produces: + // - application/json + // + // Schemes: http, https + // + // Responses: + // 200: MessagesSummaryResponse + // 400: ErrorResponse + + start, beforeTS, limit := getStartLimit(r) + + messages, err := storage.List(start, beforeTS, limit) + if err != nil { + httpError(w, err.Error()) + return + } + + stats := storage.StatsGet() + + var res MessagesSummary + + res.Start = start + res.Messages = messages + res.Count = float64(len(messages)) // legacy - now undocumented in API specs + res.Total = stats.Total + res.Unread = stats.Unread + res.Tags = stats.Tags + res.MessagesCount = stats.Total + + w.Header().Add("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(res); err != nil { + httpError(w, err.Error()) + } +} + +// swagger:parameters SetReadStatusParams +type setReadStatusParams struct { + // in: body + Body struct { + // Read status + // + // required: false + // default: false + // example: true + Read bool + + // Array of message database IDs + // + // required: false + // example: ["4oRBnPtCXgAqZniRhzLNmS", "hXayS6wnCgNnt6aFTvmOF6"] + IDs []string + } +} + +// SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs +// If no IDs are provided then all messages are updated. +func SetReadStatus(w http.ResponseWriter, r *http.Request) { + // swagger:route PUT /api/v1/messages messages SetReadStatusParams + // + // # Set read status + // + // If no IDs are provided then all messages are updated. + // + // Consumes: + // - application/json + // + // Produces: + // - text/plain + // + // Schemes: http, https + // + // Responses: + // 200: OKResponse + // 400: ErrorResponse + + decoder := json.NewDecoder(r.Body) + + var data struct { + Read bool + IDs []string + } + + err := decoder.Decode(&data) + if err != nil { + httpError(w, err.Error()) + return + } + + ids := data.IDs + + if len(ids) == 0 { + if data.Read { + err := storage.MarkAllRead() + if err != nil { + httpError(w, err.Error()) + return + } + } else { + err := storage.MarkAllUnread() + if err != nil { + httpError(w, err.Error()) + return + } + } + } else { + if data.Read { + for _, id := range ids { + if err := storage.MarkRead(id); err != nil { + httpError(w, err.Error()) + return + } + } + } else { + for _, id := range ids { + if err := storage.MarkUnread(id); err != nil { + httpError(w, err.Error()) + return + } + } + } + } + + w.Header().Add("Content-Type", "text/plain") + _, _ = w.Write([]byte("ok")) +} + +// swagger:parameters DeleteMessagesParams +type deleteMessagesParams struct { + // Delete request + // in: body + Body struct { + // Array of message database IDs + // + // required: false + // example: ["4oRBnPtCXgAqZniRhzLNmS", "hXayS6wnCgNnt6aFTvmOF6"] + IDs []string + } +} + +// DeleteMessages (method: DELETE) deletes all messages matching IDS. +func DeleteMessages(w http.ResponseWriter, r *http.Request) { + // swagger:route DELETE /api/v1/messages messages DeleteMessagesParams + // + // # Delete messages + // + // Delete individual or all messages. If no IDs are provided then all messages are deleted. + // + // Consumes: + // - application/json + // + // Produces: + // - text/plain + // + // Schemes: http, https + // + // Responses: + // 200: OKResponse + // 400: ErrorResponse + + decoder := json.NewDecoder(r.Body) + var data struct { + IDs []string + } + err := decoder.Decode(&data) + if err != nil || len(data.IDs) == 0 { + if err := storage.DeleteAllMessages(); err != nil { + httpError(w, err.Error()) + return + } + } else { + if err := storage.DeleteMessages(data.IDs); err != nil { + httpError(w, err.Error()) + return + } + } + + w.Header().Add("Content-Type", "text/plain") + _, _ = w.Write([]byte("ok")) +} + +// swagger:parameters SearchParams +type searchParams struct { + // Search query + // + // in: query + // required: true + // type: string + // example: search words + Query string `json:"query"` + + // Pagination offset + // + // in: query + // required: false + // type integer + // example: 100 + Start string `json:"start"` + + // Limit results + // + // in: query + // required: false + // type integer + // example: 50 + Limit string `json:"limit"` + + // [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: "Pacific/Auckland"). + // + // in: query + // required: false + // type string + TZ string `json:"tz"` +} + +// Search returns the latest messages as JSON +func Search(w http.ResponseWriter, r *http.Request) { + // swagger:route GET /api/v1/search messages SearchParams + // + // # Search messages + // + // Returns messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/), sorted by received date (descending). + // + // Produces: + // - application/json + // + // Schemes: http, https + // + // Responses: + // 200: MessagesSummaryResponse + // 400: ErrorResponse + + search := strings.TrimSpace(r.URL.Query().Get("query")) + if search == "" { + httpError(w, "Error: no search query") + return + } + + start, beforeTS, limit := getStartLimit(r) + + messages, results, err := storage.Search(search, r.URL.Query().Get("tz"), start, beforeTS, limit) + if err != nil { + httpError(w, err.Error()) + return + } + + stats := storage.StatsGet() + + var res MessagesSummary + + res.Start = start + res.Messages = messages + res.Count = float64(len(messages)) // legacy - now undocumented in API specs + res.Total = stats.Total // total messages in mailbox + res.MessagesCount = float64(results) + res.Unread = stats.Unread + res.Tags = stats.Tags + + w.Header().Add("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(res); err != nil { + httpError(w, err.Error()) + } +} + +// swagger:parameters DeleteSearchParams +type deleteSearchParams struct { + // Search query + // + // in: query + // required: true + // type: string + // example: search words + Query string `json:"query"` + + // [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: "Pacific/Auckland"). + // + // in: query + // required: false + // type string + TZ string `json:"tz"` +} + +// DeleteSearch will delete all messages matching a search +func DeleteSearch(w http.ResponseWriter, r *http.Request) { + // swagger:route DELETE /api/v1/search messages DeleteSearchParams + // + // # Delete messages by search + // + // Delete all messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/). + // + // Produces: + // - application/json + // + // Schemes: http, https + // + // Responses: + // 200: OKResponse + // 400: ErrorResponse + + search := strings.TrimSpace(r.URL.Query().Get("query")) + if search == "" { + httpError(w, "Error: no search query") + return + } + + if err := storage.DeleteSearch(search, r.URL.Query().Get("tz")); err != nil { + httpError(w, err.Error()) + return + } + + w.Header().Add("Content-Type", "text/plain") + _, _ = w.Write([]byte("ok")) +} diff --git a/server/apiv1/other.go b/server/apiv1/other.go new file mode 100644 index 0000000000..e8e24a0f87 --- /dev/null +++ b/server/apiv1/other.go @@ -0,0 +1,238 @@ +package apiv1 + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "github.com/axllent/mailpit/config" + "github.com/axllent/mailpit/internal/htmlcheck" + "github.com/axllent/mailpit/internal/linkcheck" + "github.com/axllent/mailpit/internal/spamassassin" + "github.com/axllent/mailpit/internal/storage" + "github.com/gorilla/mux" + "github.com/jhillyerd/enmime" +) + +// swagger:parameters HTMLCheckParams +type htmlCheckParams struct { + // Message database ID or "latest" + // + // in: path + // description: Message database ID or "latest" + // required: true + // example: 4oRBnPtCXgAqZniRhzLNmS + ID string +} + +// HTMLCheckResponse summary response +type HTMLCheckResponse = htmlcheck.Response + +// HTMLCheck returns a summary of the HTML client support +func HTMLCheck(w http.ResponseWriter, r *http.Request) { + // swagger:route GET /api/v1/message/{ID}/html-check Other HTMLCheckParams + // + // # HTML check + // + // Returns the summary of the message HTML checker. + // + // The ID can be set to `latest` to return the latest message. + // + // Produces: + // - application/json + // + // Schemes: http, https + // + // Responses: + // 200: HTMLCheckResponse + // 400: ErrorResponse + // 404: NotFoundResponse + + vars := mux.Vars(r) + id := vars["id"] + + if id == "latest" { + var err error + id, err = storage.LatestID(r) + if err != nil { + fourOFour(w) + return + } + } + + raw, err := storage.GetMessageRaw(id) + if err != nil { + fourOFour(w) + return + } + + e := bytes.NewReader(raw) + + parser := enmime.NewParser(enmime.DisableCharacterDetection(true)) + + msg, err := parser.ReadEnvelope(e) + if err != nil { + httpError(w, err.Error()) + return + } + + if msg.HTML == "" { + httpError(w, "message does not contain HTML") + return + } + + checks, err := htmlcheck.RunTests(msg.HTML) + if err != nil { + httpError(w, err.Error()) + return + } + + w.Header().Add("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(checks); err != nil { + httpError(w, err.Error()) + } +} + +// swagger:parameters LinkCheckParams +type linkCheckParams struct { + // Message database ID or "latest" + // + // in: path + // required: true + // example: 4oRBnPtCXgAqZniRhzLNmS + ID string + + // Follow redirects + // + // in: query + // required: false + // default: false + // example: false + Follow string `json:"follow"` +} + +// LinkCheckResponse summary response +type LinkCheckResponse = linkcheck.Response + +// LinkCheck returns a summary of links in the email +func LinkCheck(w http.ResponseWriter, r *http.Request) { + // swagger:route GET /api/v1/message/{ID}/link-check Other LinkCheckParams + // + // # Link check + // + // Returns the summary of the message Link checker. + // + // The ID can be set to `latest` to return the latest message. + // + // Produces: + // - application/json + // + // Schemes: http, https + // + // Responses: + // 200: LinkCheckResponse + // 400: ErrorResponse + // 404: NotFoundResponse + + if config.DemoMode { + httpError(w, "this functionality has been disabled for demonstration purposes") + return + } + + vars := mux.Vars(r) + id := vars["id"] + + if id == "latest" { + var err error + id, err = storage.LatestID(r) + if err != nil { + fourOFour(w) + return + } + } + + msg, err := storage.GetMessage(id) + if err != nil { + fourOFour(w) + return + } + + f := r.URL.Query().Get("follow") + followRedirects := f == "true" || f == "1" + + summary, err := linkcheck.RunTests(msg, followRedirects) + if err != nil { + httpError(w, err.Error()) + return + } + + w.Header().Add("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(summary); err != nil { + httpError(w, err.Error()) + } +} + +// swagger:parameters SpamAssassinCheckParams +type spamAssassinCheckParams struct { + // Message database ID or "latest" + // + // in: path + // required: true + // example: 4oRBnPtCXgAqZniRhzLNmS + ID string +} + +// SpamAssassinResponse summary response +type SpamAssassinResponse = spamassassin.Result + +// SpamAssassinCheck returns a summary of SpamAssassin results (if enabled) +func SpamAssassinCheck(w http.ResponseWriter, r *http.Request) { + // swagger:route GET /api/v1/message/{ID}/sa-check Other SpamAssassinCheckParams + // + // # SpamAssassin check + // + // Returns the SpamAssassin summary (if enabled) of the message. + // + // The ID can be set to `latest` to return the latest message. + // + // Produces: + // - application/json + // + // Schemes: http, https + // + // Responses: + // 200: SpamAssassinResponse + // 400: ErrorResponse + // 404: NotFoundResponse + + vars := mux.Vars(r) + id := vars["id"] + + if id == "latest" { + var err error + id, err = storage.LatestID(r) + if err != nil { + w.WriteHeader(404) + fmt.Fprint(w, err.Error()) + return + } + } + + msg, err := storage.GetMessageRaw(id) + if err != nil { + fourOFour(w) + return + } + + summary, err := spamassassin.Check(msg) + if err != nil { + httpError(w, err.Error()) + return + } + + w.Header().Add("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(summary); err != nil { + httpError(w, err.Error()) + } +} diff --git a/server/apiv1/release.go b/server/apiv1/release.go index e11f505271..b0e76b8f80 100644 --- a/server/apiv1/release.go +++ b/server/apiv1/release.go @@ -17,25 +17,48 @@ import ( "github.com/lithammer/shortuuid/v4" ) +// swagger:parameters ReleaseMessageParams +type releaseMessageParams struct { + // Message database ID + // + // in: path + // description: Message database ID + // required: true + // example: 4oRBnPtCXgAqZniRhzLNmS + ID string + + // in: body + Body struct { + // Array of email addresses to relay the message to + // + // required: true + // example: ["user1@example.com", "user2@example.com"] + To []string + } +} + // ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server. func ReleaseMessage(w http.ResponseWriter, r *http.Request) { - // swagger:route POST /api/v1/message/{ID}/release message ReleaseMessage + // swagger:route POST /api/v1/message/{ID}/release message ReleaseMessageParams // // # Release message // // Release a message via a pre-configured external SMTP server. This is only enabled if message relaying has been configured. // + // The ID can be set to `latest` to reference the latest message. + // // Consumes: - // - application/json + // - application/json // // Produces: - // - text/plain + // - text/plain // // Schemes: http, https // // Responses: - // 200: OKResponse - // default: ErrorResponse + // 200: OKResponse + // 400: ErrorResponse + // 404: NotFoundResponse if config.DemoMode { httpError(w, "this functionality has been disabled for demonstration purposes") @@ -54,7 +77,9 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) { decoder := json.NewDecoder(r.Body) - data := releaseMessageRequestBody{} + var data struct { + To []string + } if err := decoder.Decode(&data); err != nil { httpError(w, err.Error()) diff --git a/server/apiv1/send.go b/server/apiv1/send.go index b4e21b3385..55ec8746bf 100644 --- a/server/apiv1/send.go +++ b/server/apiv1/send.go @@ -17,7 +17,7 @@ import ( "github.com/jhillyerd/enmime" ) -// swagger:parameters SendMessage +// swagger:parameters SendMessageParams type sendMessageParams struct { // in: body Body *SendRequest @@ -108,13 +108,6 @@ type SendRequest struct { Headers map[string]string } -// SendMessageConfirmation struct -type SendMessageConfirmation struct { - // Database ID - // example: iAfZVVe2UQFNSG5BAjgYwa - ID string -} - // JSONErrorMessage struct type JSONErrorMessage struct { // Error message @@ -122,25 +115,41 @@ type JSONErrorMessage struct { Error 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 +} + +// SendMessageConfirmation struct +type SendMessageConfirmation struct { + // Database ID + // example: iAfZVVe2UQfNSG5BAjgYwa + ID 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 + // swagger:route POST /api/v1/send message SendMessageParams // // # Send a message // // Send a message via the HTTP API. // // Consumes: - // - application/json + // - application/json // // Produces: - // - application/json + // - application/json // // Schemes: http, https // // Responses: - // 200: sendMessageResponse - // default: jsonErrorResponse + // 200: sendMessageResponse + // 400: jsonErrorResponse if config.DemoMode { httpJSONError(w, "this functionality has been disabled for demonstration purposes") diff --git a/server/apiv1/structs.go b/server/apiv1/structs.go index 7055afcc0b..2130784cd5 100644 --- a/server/apiv1/structs.go +++ b/server/apiv1/structs.go @@ -1,39 +1,9 @@ package apiv1 import ( - "github.com/axllent/mailpit/internal/htmlcheck" - "github.com/axllent/mailpit/internal/linkcheck" - "github.com/axllent/mailpit/internal/spamassassin" "github.com/axllent/mailpit/internal/storage" ) -// MessagesSummary is a summary of a list of messages -type MessagesSummary struct { - // Total number of messages in mailbox - Total float64 `json:"total"` - - // Total number of unread messages in mailbox - Unread float64 `json:"unread"` - - // Legacy - now undocumented in API specs but left for backwards compatibility. - // Removed from API documentation 2023-07-12 - // swagger:ignore - Count float64 `json:"count"` - - // Total number of messages matching current query - MessagesCount float64 `json:"messages_count"` - - // Pagination offset - Start int `json:"start"` - - // All current tags - Tags []string `json:"tags"` - - // Messages summary - // in: body - Messages []storage.MessageSummary `json:"messages"` -} - // The following structs & aliases are provided for easy import // and understanding of the JSON structure. @@ -45,12 +15,3 @@ type Message = storage.Message // Attachment summary type Attachment = storage.Attachment - -// HTMLCheckResponse summary -type HTMLCheckResponse = htmlcheck.Response - -// LinkCheckResponse summary -type LinkCheckResponse = linkcheck.Response - -// SpamAssassinResponse summary -type SpamAssassinResponse = spamassassin.Result diff --git a/server/apiv1/swagger.go b/server/apiv1/swagger.go index d9977965c2..b043db255b 100644 --- a/server/apiv1/swagger.go +++ b/server/apiv1/swagger.go @@ -1,177 +1,8 @@ package apiv1 -import "github.com/axllent/mailpit/internal/stats" - // These structs are for the purpose of defining swagger HTTP parameters & responses -// Application information -// swagger:response InfoResponse -type infoResponse struct { - // Application information - // - // in: body - Body stats.AppInformation -} - -// Web UI configuration -// swagger:response WebUIConfigurationResponse -type webUIConfigurationResponse struct { - // Web UI configuration settings - // - // in: body - Body webUIConfiguration -} - -// Message summary -// swagger:response MessagesSummaryResponse -type messagesSummaryResponse struct { - // The message summary - // in: body - Body MessagesSummary -} - -// Message headers -// swagger:model MessageHeaders -type messageHeaders map[string][]string - -// swagger:parameters DeleteMessages -type deleteMessagesParams struct { - // in: body - Body *deleteMessagesRequestBody -} - -// Delete request -// swagger:model DeleteRequest -type deleteMessagesRequestBody struct { - // Array of message database IDs - // - // required: false - // example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"] - IDs []string -} - -// swagger:parameters SetReadStatus -type setReadStatusParams struct { - // in: body - Body *setReadStatusRequestBody -} - -// Set read status request -// swagger:model setReadStatusRequestBody -type setReadStatusRequestBody struct { - // Read status - // - // required: false - // default: false - // example: true - Read bool - - // Array of message database IDs - // - // required: false - // example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"] - IDs []string -} - -// swagger:parameters SetTags -type setTagsParams struct { - // in: body - Body *setTagsRequestBody -} - -// Set tags request -// swagger:model setTagsRequestBody -type setTagsRequestBody struct { - // Array of tag names to set - // - // required: true - // example: ["Tag 1", "Tag 2"] - Tags []string - - // Array of message database IDs - // - // required: true - // example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"] - IDs []string -} - -// swagger:parameters RenameTag -type renameTagParams struct { - // in: body - Body *renameTagRequestBody -} - -// Rename tag request -// swagger:model renameTagRequestBody -type renameTagRequestBody struct { - // New name - // - // required: true - // example: New name - Name string -} - -// swagger:parameters ReleaseMessage -type releaseMessageParams struct { - // Message database ID - // - // in: path - // description: Message database ID - // required: true - ID string - - // in: body - Body *releaseMessageRequestBody -} - -// Release request -// swagger:model releaseMessageRequestBody -type releaseMessageRequestBody struct { - // Array of email addresses to relay the message to - // required: true - // example: ["user1@example.com", "user2@example.com"] - To []string -} - -// swagger:parameters HTMLCheck -type htmlCheckParams struct { - // Message database ID or "latest" - // - // in: path - // description: Message database ID or "latest" - // required: true - ID string -} - -// swagger:parameters LinkCheck -type linkCheckParams struct { - // Message database ID or "latest" - // - // in: path - // description: Message database ID or "latest" - // required: true - ID string - - // Follow redirects - // - // in: query - // description: Follow redirects - // required: false - // default: false - Follow string `json:"follow"` -} - -// swagger:parameters SpamAssassinCheck -type spamAssassinCheckParams struct { - // Message database ID or "latest" - // - // in: path - // description: Message database ID or "latest" - // required: true - ID string -} - -// Binary data response inherits the attachment's content type. +// Binary data response which inherits the attachment's content type. // swagger:response BinaryResponse type binaryResponse string @@ -183,11 +14,15 @@ type textResponse string // swagger:response HTMLResponse type htmlResponse string -// HTTP error response will return with a >= 400 response code +// Server error will return with a 400 status code +// with the error message in the body // swagger:response ErrorResponse -// example: invalid request type errorResponse string +// Not found error will return a 404 status code +// swagger:response NotFoundResponse +type notFoundResponse string + // Plain text "ok" response // swagger:response OKResponse type okResponse string @@ -196,15 +31,6 @@ type okResponse string // 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 { diff --git a/server/apiv1/tags.go b/server/apiv1/tags.go index f9e154b135..8ba4c9e2ea 100644 --- a/server/apiv1/tags.go +++ b/server/apiv1/tags.go @@ -18,13 +18,13 @@ func GetAllTags(w http.ResponseWriter, _ *http.Request) { // Returns a JSON array of all unique message tags. // // Produces: - // - application/json + // - application/json // // Schemes: http, https // // Responses: - // 200: ArrayResponse - // default: ErrorResponse + // 200: ArrayResponse + // 400: ErrorResponse w.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(storage.GetAllTags()); err != nil { @@ -32,25 +32,43 @@ func GetAllTags(w http.ResponseWriter, _ *http.Request) { } } +// swagger:parameters SetTagsParams +type setTagsParams struct { + // in: body + Body struct { + // Array of tag names to set + // + // required: true + // example: ["Tag 1", "Tag 2"] + Tags []string + + // Array of message database IDs + // + // required: true + // example: ["4oRBnPtCXgAqZniRhzLNmS", "hXayS6wnCgNnt6aFTvmOF6"] + IDs []string + } +} + // SetMessageTags (method: PUT) will set the tags for all provided IDs func SetMessageTags(w http.ResponseWriter, r *http.Request) { - // swagger:route PUT /api/v1/tags tags SetTags + // swagger:route PUT /api/v1/tags tags SetTagsParams // // # Set message tags // // This will overwrite any existing tags for selected message database IDs. To remove all tags from a message, pass an empty tags array. // // Consumes: - // - application/json + // - application/json // // Produces: - // - text/plain + // - text/plain // // Schemes: http, https // // Responses: - // 200: OKResponse - // default: ErrorResponse + // 200: OKResponse + // 400: ErrorResponse decoder := json.NewDecoder(r.Body) @@ -80,29 +98,42 @@ func SetMessageTags(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) } +// swagger:parameters RenameTagParams +type renameTagParams struct { + // The url-encoded tag name to rename + // + // in: path + // required: true + // type: string + // example: Old name + Tag string + + // in: body + Body struct { + // New name + // + // required: true + // example: New name + Name string + } +} + // RenameTag (method: PUT) used to rename a tag func RenameTag(w http.ResponseWriter, r *http.Request) { - // swagger:route PUT /api/v1/tags/{tag} tags RenameTag + // swagger:route PUT /api/v1/tags/{Tag} tags RenameTagParams // // # Rename a tag // - // Renames a tag. + // Renames an existing tag. // // Produces: - // - text/plain + // - text/plain // // Schemes: http, https // - // Parameters: - // + name: tag - // in: path - // description: The url-encoded tag name to rename - // required: true - // type: string - // // Responses: - // 200: OKResponse - // default: ErrorResponse + // 200: OKResponse + // 400: ErrorResponse vars := mux.Vars(r) @@ -131,29 +162,32 @@ func RenameTag(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) } +// swagger:parameters DeleteTagParams +type deleteTagParams struct { + // The url-encoded tag name to delete + // + // in: path + // required: true + // example: My tag + Tag string +} + // DeleteTag (method: DELETE) used to delete a tag func DeleteTag(w http.ResponseWriter, r *http.Request) { - // swagger:route DELETE /api/v1/tags/{tag} tags DeleteTag + // swagger:route DELETE /api/v1/tags/{Tag} tags DeleteTagParams // // # Delete a tag // - // Deletes a tag. This will not delete any messages with this tag. + // Deletes a tag. This will not delete any messages with the tag, but will remove the tag from any messages containing the tag. // // Produces: - // - text/plain + // - text/plain // // Schemes: http, https // - // Parameters: - // + name: tag - // in: path - // description: The url-encoded tag name to delete - // required: true - // type: string - // // Responses: - // 200: OKResponse - // default: ErrorResponse + // 200: OKResponse + // 400: ErrorResponse vars := mux.Vars(r) diff --git a/server/apiv1/testing.go b/server/apiv1/testing.go new file mode 100644 index 0000000000..987bf072f9 --- /dev/null +++ b/server/apiv1/testing.go @@ -0,0 +1,159 @@ +package apiv1 + +import ( + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/axllent/mailpit/config" + "github.com/axllent/mailpit/internal/storage" + "github.com/gorilla/mux" +) + +// swagger:parameters GetMessageHTMLParams +type getMessageHTMLParams struct { + // Message database ID or "latest" + // + // in: path + // required: true + // example: 4oRBnPtCXgAqZniRhzLNmS + ID string +} + +// GetMessageHTML (method: GET) returns a rendered version of a message's HTML part +func GetMessageHTML(w http.ResponseWriter, r *http.Request) { + // swagger:route GET /view/{ID}.html testing GetMessageHTMLParams + // + // # Render message HTML part + // + // Renders just the message's HTML part which can be used for UI integration testing. + // Attached inline images are modified to link to the API provided they exist. + // Note that is the message does not contain a HTML part then an 404 error is returned. + // + // The ID can be set to `latest` to return the latest message. + // + // Produces: + // - text/html + // + // Schemes: http, https + // + // Responses: + // 200: HTMLResponse + // 400: ErrorResponse + // 404: NotFoundResponse + + vars := mux.Vars(r) + + id := vars["id"] + + if id == "latest" { + var err error + id, err = storage.LatestID(r) + if err != nil { + w.WriteHeader(404) + fmt.Fprint(w, err.Error()) + return + } + } + + msg, err := storage.GetMessage(id) + if err != nil { + w.WriteHeader(404) + fmt.Fprint(w, "Message not found") + return + } + if msg.HTML == "" { + w.WriteHeader(404) + fmt.Fprint(w, "This message does not contain a HTML part") + return + } + + html := linkInlineImages(msg) + w.Header().Add("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(html)) +} + +// swagger:parameters GetMessageTextParams +type getMessageTextParams struct { + // Message database ID or "latest" + // + // in: path + // required: true + // example: 4oRBnPtCXgAqZniRhzLNmS + ID string +} + +// GetMessageText (method: GET) returns a message's text part +func GetMessageText(w http.ResponseWriter, r *http.Request) { + // swagger:route GET /view/{ID}.txt testing GetMessageTextParams + // + // # Render message text part + // + // Renders just the message's text part which can be used for UI integration testing. + // + // The ID can be set to `latest` to return the latest message. + // + // Produces: + // - text/plain + // + // Schemes: http, https + // + // Responses: + // 200: TextResponse + // 400: ErrorResponse + // 404: NotFoundResponse + + vars := mux.Vars(r) + + id := vars["id"] + + if id == "latest" { + var err error + id, err = storage.LatestID(r) + if err != nil { + w.WriteHeader(404) + fmt.Fprint(w, err.Error()) + return + } + } + + msg, err := storage.GetMessage(id) + if err != nil { + w.WriteHeader(404) + fmt.Fprint(w, "Message not found") + return + } + + w.Header().Add("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(msg.Text)) +} + +// This will rewrite all inline image paths to API URLs +func linkInlineImages(msg *storage.Message) string { + html := msg.HTML + + for _, a := range msg.Inline { + if a.ContentID != "" { + re := regexp.MustCompile(`(?i)(=["\']?)(cid:` + regexp.QuoteMeta(a.ContentID) + `)(["|\'|\\s|\\/|>|;])`) + u := config.Webroot + "api/v1/message/" + msg.ID + "/part/" + a.PartID + matches := re.FindAllStringSubmatch(html, -1) + for _, m := range matches { + html = strings.ReplaceAll(html, m[0], m[1]+u+m[3]) + } + } + } + + for _, a := range msg.Attachments { + if a.ContentID != "" { + re := regexp.MustCompile(`(?i)(=["\']?)(cid:` + regexp.QuoteMeta(a.ContentID) + `)(["|\'|\\s|\\/|>|;])`) + u := config.Webroot + "api/v1/message/" + msg.ID + "/part/" + a.PartID + matches := re.FindAllStringSubmatch(html, -1) + for _, m := range matches { + html = strings.ReplaceAll(html, m[0], m[1]+u+m[3]) + } + } + } + + return html +} diff --git a/server/apiv1/thumbnails.go b/server/apiv1/thumbnails.go index 3d76fc8f14..ea5526ed04 100644 --- a/server/apiv1/thumbnails.go +++ b/server/apiv1/thumbnails.go @@ -22,35 +22,43 @@ var ( thumbHeight = 120 ) +// swagger:parameters ThumbnailParams +type thumbnailParams struct { + // Message database ID or "latest" + // + // in: path + // required: true + // example: 4oRBnPtCXgAqZniRhzLNmS + ID string + + // Attachment part ID + // + // in: path + // required: true + // example: 2 + PartID string +} + // Thumbnail returns a thumbnail image for an attachment (images only) func Thumbnail(w http.ResponseWriter, r *http.Request) { - // swagger:route GET /api/v1/message/{ID}/part/{PartID}/thumb message Thumbnail + // swagger:route GET /api/v1/message/{ID}/part/{PartID}/thumb message ThumbnailParams // // # Get an attachment image thumbnail // // This will return a cropped 180x120 JPEG thumbnail of an image attachment. // If the image is smaller than 180x120 then the image is padded. If the attachment is not an image then a blank image is returned. // + // The ID can be set to `latest` to return the latest message. + // // Produces: - // - image/jpeg + // - image/jpeg // // Schemes: http, https // - // Parameters: - // + name: ID - // in: path - // description: Database ID - // required: true - // type: string - // + name: PartID - // in: path - // description: Attachment part ID - // required: true - // type: string - // // Responses: - // 200: BinaryResponse - // default: ErrorResponse + // 200: BinaryResponse + // 400: ErrorResponse + vars := mux.Vars(r) id := vars["id"] diff --git a/server/handlers/messages.go b/server/handlers/messages.go index 70de82d078..20766047ec 100644 --- a/server/handlers/messages.go +++ b/server/handlers/messages.go @@ -1,15 +1,12 @@ package handlers import ( - "fmt" "net/http" "net/url" - "regexp" "strings" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/storage" - "github.com/gorilla/mux" ) // RedirectToLatestMessage (method: GET) redirects the web UI to the latest message @@ -44,142 +41,3 @@ func RedirectToLatestMessage(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, uri, 302) } - -// GetMessageHTML (method: GET) returns a rendered version of a message's HTML part -func GetMessageHTML(w http.ResponseWriter, r *http.Request) { - // swagger:route GET /view/{ID}.html testing GetMessageHTML - // - // # Render message HTML part - // - // Renders just the message's HTML part which can be used for UI integration testing. - // Attached inline images are modified to link to the API provided they exist. - // Note that is the message does not contain a HTML part then an 404 error is returned. - // - // The ID can be set to `latest` to return the latest message. - // - // Produces: - // - text/html - // - // Schemes: http, https - // - // Parameters: - // + name: ID - // in: path - // description: Database ID or latest - // required: true - // type: string - // - // Responses: - // 200: HTMLResponse - // default: ErrorResponse - - vars := mux.Vars(r) - - id := vars["id"] - - if id == "latest" { - var err error - id, err = storage.LatestID(r) - if err != nil { - w.WriteHeader(404) - fmt.Fprint(w, err.Error()) - return - } - } - - msg, err := storage.GetMessage(id) - if err != nil { - w.WriteHeader(404) - fmt.Fprint(w, "Message not found") - return - } - if msg.HTML == "" { - w.WriteHeader(404) - fmt.Fprint(w, "This message does not contain a HTML part") - return - } - - html := linkInlineImages(msg) - w.Header().Add("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write([]byte(html)) -} - -// GetMessageText (method: GET) returns a message's text part -func GetMessageText(w http.ResponseWriter, r *http.Request) { - // swagger:route GET /view/{ID}.txt testing GetMessageText - // - // # Render message text part - // - // Renders just the message's text part which can be used for UI integration testing. - // - // The ID can be set to `latest` to return the latest message. - // - // Produces: - // - text/plain - // - // Schemes: http, https - // - // Parameters: - // + name: ID - // in: path - // description: Database ID or latest - // required: true - // type: string - // - // Responses: - // 200: TextResponse - // default: ErrorResponse - - vars := mux.Vars(r) - - id := vars["id"] - - if id == "latest" { - var err error - id, err = storage.LatestID(r) - if err != nil { - w.WriteHeader(404) - fmt.Fprint(w, err.Error()) - return - } - } - - msg, err := storage.GetMessage(id) - if err != nil { - w.WriteHeader(404) - fmt.Fprint(w, "Message not found") - return - } - - w.Header().Add("Content-Type", "text/plain; charset=utf-8") - _, _ = w.Write([]byte(msg.Text)) -} - -// This will rewrite all inline image paths to API URLs -func linkInlineImages(msg *storage.Message) string { - html := msg.HTML - - for _, a := range msg.Inline { - if a.ContentID != "" { - re := regexp.MustCompile(`(?i)(=["\']?)(cid:` + regexp.QuoteMeta(a.ContentID) + `)(["|\'|\\s|\\/|>|;])`) - u := config.Webroot + "api/v1/message/" + msg.ID + "/part/" + a.PartID - matches := re.FindAllStringSubmatch(html, -1) - for _, m := range matches { - html = strings.ReplaceAll(html, m[0], m[1]+u+m[3]) - } - } - } - - for _, a := range msg.Attachments { - if a.ContentID != "" { - re := regexp.MustCompile(`(?i)(=["\']?)(cid:` + regexp.QuoteMeta(a.ContentID) + `)(["|\'|\\s|\\/|>|;])`) - u := config.Webroot + "api/v1/message/" + msg.ID + "/part/" + a.PartID - matches := re.FindAllStringSubmatch(html, -1) - for _, m := range matches { - html = strings.ReplaceAll(html, m[0], m[1]+u+m[3]) - } - } - } - - return html -} diff --git a/server/server.go b/server/server.go index 51043f08bf..c942b29612 100644 --- a/server/server.go +++ b/server/server.go @@ -81,8 +81,8 @@ func Listen() { r.HandleFunc(config.Webroot+"view/latest", middleWareFunc(handlers.RedirectToLatestMessage)).Methods("GET") // frontend testing - r.HandleFunc(config.Webroot+"view/{id}.html", middleWareFunc(handlers.GetMessageHTML)).Methods("GET") - r.HandleFunc(config.Webroot+"view/{id}.txt", middleWareFunc(handlers.GetMessageText)).Methods("GET") + r.HandleFunc(config.Webroot+"view/{id}.html", middleWareFunc(apiv1.GetMessageHTML)).Methods("GET") + r.HandleFunc(config.Webroot+"view/{id}.txt", middleWareFunc(apiv1.GetMessageText)).Methods("GET") // web UI via virtual index.html r.PathPrefix(config.Webroot + "view/").Handler(middleWareFunc(index)).Methods("GET") diff --git a/server/ui/api/v1/swagger.json b/server/ui/api/v1/swagger.json index 9be9d5acbb..f27f275253 100644 --- a/server/ui/api/v1/swagger.json +++ b/server/ui/api/v1/swagger.json @@ -40,9 +40,9 @@ "operationId": "AppInformation", "responses": { "200": { - "$ref": "#/responses/InfoResponse" + "$ref": "#/responses/AppInfoResponse" }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" } } @@ -62,10 +62,11 @@ "message" ], "summary": "Get message summary", - "operationId": "Message", + "operationId": "GetMessageParams", "parameters": [ { "type": "string", + "example": "4oRBnPtCXgAqZniRhzLNmS", "description": "Message database ID or \"latest\"", "name": "ID", "in": "path", @@ -79,15 +80,18 @@ "$ref": "#/definitions/Message" } }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" + }, + "404": { + "$ref": "#/responses/NotFoundResponse" } } } }, "/api/v1/message/{ID}/headers": { "get": { - "description": "Returns the message headers as an array.\n\nThe ID can be set to `latest` to return the latest message headers.", + "description": "Returns the message headers as an array. Note that header keys are returned alphabetically.\n\nThe ID can be set to `latest` to return the latest message headers.", "produces": [ "application/json" ], @@ -99,10 +103,11 @@ "message" ], "summary": "Get message headers", - "operationId": "Headers", + "operationId": "GetHeadersParams", "parameters": [ { "type": "string", + "example": "4oRBnPtCXgAqZniRhzLNmS", "description": "Message database ID or \"latest\"", "name": "ID", "in": "path", @@ -111,20 +116,23 @@ ], "responses": { "200": { - "description": "MessageHeaders", + "description": "MessageHeadersResponse", "schema": { - "$ref": "#/definitions/MessageHeaders" + "$ref": "#/definitions/MessageHeadersResponse" } }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" + }, + "404": { + "$ref": "#/responses/NotFoundResponse" } } } }, "/api/v1/message/{ID}/html-check": { "get": { - "description": "Returns the summary of the message HTML checker.", + "description": "Returns the summary of the message HTML checker.\n\nThe ID can be set to `latest` to return the latest message.", "produces": [ "application/json" ], @@ -136,10 +144,11 @@ "Other" ], "summary": "HTML check", - "operationId": "HTMLCheck", + "operationId": "HTMLCheckParams", "parameters": [ { "type": "string", + "example": "4oRBnPtCXgAqZniRhzLNmS", "description": "Message database ID or \"latest\"", "name": "ID", "in": "path", @@ -153,15 +162,18 @@ "$ref": "#/definitions/HTMLCheckResponse" } }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" + }, + "404": { + "$ref": "#/responses/NotFoundResponse" } } } }, "/api/v1/message/{ID}/link-check": { "get": { - "description": "Returns the summary of the message Link checker.", + "description": "Returns the summary of the message Link checker.\n\nThe ID can be set to `latest` to return the latest message.", "produces": [ "application/json" ], @@ -173,10 +185,11 @@ "Other" ], "summary": "Link check", - "operationId": "LinkCheck", + "operationId": "LinkCheckParams", "parameters": [ { "type": "string", + "example": "4oRBnPtCXgAqZniRhzLNmS", "description": "Message database ID or \"latest\"", "name": "ID", "in": "path", @@ -185,6 +198,7 @@ { "type": "string", "default": "false", + "example": "false", "x-go-name": "Follow", "description": "Follow redirects", "name": "follow", @@ -198,15 +212,18 @@ "$ref": "#/definitions/LinkCheckResponse" } }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" + }, + "404": { + "$ref": "#/responses/NotFoundResponse" } } } }, "/api/v1/message/{ID}/part/{PartID}": { "get": { - "description": "This will return the attachment part using the appropriate Content-Type.", + "description": "This will return the attachment part using the appropriate Content-Type.\n\nThe ID can be set to `latest` to reference the latest message.", "produces": [ "application/*", "image/*", @@ -220,17 +237,19 @@ "message" ], "summary": "Get message attachment", - "operationId": "Attachment", + "operationId": "AttachmentParams", "parameters": [ { "type": "string", - "description": "Message database ID", + "example": "4oRBnPtCXgAqZniRhzLNmS", + "description": "Message database ID or \"latest\"", "name": "ID", "in": "path", "required": true }, { "type": "string", + "example": "2", "description": "Attachment part ID", "name": "PartID", "in": "path", @@ -241,15 +260,18 @@ "200": { "$ref": "#/responses/BinaryResponse" }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" + }, + "404": { + "$ref": "#/responses/NotFoundResponse" } } } }, "/api/v1/message/{ID}/part/{PartID}/thumb": { "get": { - "description": "This will return a cropped 180x120 JPEG thumbnail of an image attachment.\nIf the image is smaller than 180x120 then the image is padded. If the attachment is not an image then a blank image is returned.", + "description": "This will return a cropped 180x120 JPEG thumbnail of an image attachment.\nIf the image is smaller than 180x120 then the image is padded. If the attachment is not an image then a blank image is returned.\n\nThe ID can be set to `latest` to return the latest message.", "produces": [ "image/jpeg" ], @@ -261,17 +283,19 @@ "message" ], "summary": "Get an attachment image thumbnail", - "operationId": "Thumbnail", + "operationId": "ThumbnailParams", "parameters": [ { "type": "string", - "description": "Database ID", + "example": "4oRBnPtCXgAqZniRhzLNmS", + "description": "Message database ID or \"latest\"", "name": "ID", "in": "path", "required": true }, { "type": "string", + "example": "2", "description": "Attachment part ID", "name": "PartID", "in": "path", @@ -282,7 +306,7 @@ "200": { "$ref": "#/responses/BinaryResponse" }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" } } @@ -302,10 +326,11 @@ "message" ], "summary": "Get message source", - "operationId": "Raw", + "operationId": "DownloadRawParams", "parameters": [ { "type": "string", + "example": "4oRBnPtCXgAqZniRhzLNmS", "description": "Message database ID or \"latest\"", "name": "ID", "in": "path", @@ -316,15 +341,18 @@ "200": { "$ref": "#/responses/TextResponse" }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" + }, + "404": { + "$ref": "#/responses/NotFoundResponse" } } } }, "/api/v1/message/{ID}/release": { "post": { - "description": "Release a message via a pre-configured external SMTP server. This is only enabled if message relaying has been configured.", + "description": "Release a message via a pre-configured external SMTP server. This is only enabled if message relaying has been configured.\n\nThe ID can be set to `latest` to reference the latest message.", "consumes": [ "application/json" ], @@ -339,10 +367,11 @@ "message" ], "summary": "Release message", - "operationId": "ReleaseMessage", + "operationId": "ReleaseMessageParams", "parameters": [ { "type": "string", + "example": "4oRBnPtCXgAqZniRhzLNmS", "description": "Message database ID", "name": "ID", "in": "path", @@ -352,7 +381,23 @@ "name": "Body", "in": "body", "schema": { - "$ref": "#/definitions/releaseMessageRequestBody" + "type": "object", + "required": [ + "To" + ], + "properties": { + "To": { + "description": "Array of email addresses to relay the message to", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "user1@example.com", + "user2@example.com" + ] + } + } } } ], @@ -360,15 +405,18 @@ "200": { "$ref": "#/responses/OKResponse" }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" + }, + "404": { + "$ref": "#/responses/NotFoundResponse" } } } }, "/api/v1/message/{ID}/sa-check": { "get": { - "description": "Returns the SpamAssassin summary (if enabled) of the message.", + "description": "Returns the SpamAssassin summary (if enabled) of the message.\n\nThe ID can be set to `latest` to return the latest message.", "produces": [ "application/json" ], @@ -380,10 +428,11 @@ "Other" ], "summary": "SpamAssassin check", - "operationId": "SpamAssassinCheck", + "operationId": "SpamAssassinCheckParams", "parameters": [ { "type": "string", + "example": "4oRBnPtCXgAqZniRhzLNmS", "description": "Message database ID or \"latest\"", "name": "ID", "in": "path", @@ -397,8 +446,11 @@ "$ref": "#/definitions/SpamAssassinResponse" } }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" + }, + "404": { + "$ref": "#/responses/NotFoundResponse" } } } @@ -417,19 +469,25 @@ "messages" ], "summary": "List messages", - "operationId": "GetMessages", + "operationId": "GetMessagesParams", "parameters": [ { "type": "integer", + "format": "int64", "default": 0, + "example": 100, + "x-go-name": "Start", "description": "Pagination offset", "name": "start", "in": "query" }, { "type": "integer", + "format": "int64", "default": 50, - "description": "Limit results", + "example": 50, + "x-go-name": "Limit", + "description": "Limit number of results", "name": "limit", "in": "query" } @@ -438,7 +496,7 @@ "200": { "$ref": "#/responses/MessagesSummaryResponse" }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" } } @@ -459,13 +517,32 @@ "messages" ], "summary": "Set read status", - "operationId": "SetReadStatus", + "operationId": "SetReadStatusParams", "parameters": [ { "name": "Body", "in": "body", "schema": { - "$ref": "#/definitions/setReadStatusRequestBody" + "type": "object", + "properties": { + "IDs": { + "description": "Array of message database IDs", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "4oRBnPtCXgAqZniRhzLNmS", + "hXayS6wnCgNnt6aFTvmOF6" + ] + }, + "Read": { + "description": "Read status", + "type": "boolean", + "default": false, + "example": true + } + } } } ], @@ -473,7 +550,7 @@ "200": { "$ref": "#/responses/OKResponse" }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" } } @@ -494,13 +571,27 @@ "messages" ], "summary": "Delete messages", - "operationId": "DeleteMessages", + "operationId": "DeleteMessagesParams", "parameters": [ { + "description": "Delete request", "name": "Body", "in": "body", "schema": { - "$ref": "#/definitions/DeleteRequest" + "type": "object", + "properties": { + "IDs": { + "description": "Array of message database IDs", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "4oRBnPtCXgAqZniRhzLNmS", + "hXayS6wnCgNnt6aFTvmOF6" + ] + } + } } } ], @@ -508,7 +599,7 @@ "200": { "$ref": "#/responses/OKResponse" }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" } } @@ -528,32 +619,37 @@ "messages" ], "summary": "Search messages", - "operationId": "MessagesSummary", + "operationId": "SearchParams", "parameters": [ { "type": "string", + "example": "search words", + "x-go-name": "Query", "description": "Search query", "name": "query", "in": "query", "required": true }, { - "type": "integer", - "default": 0, + "type": "string", + "example": "100", + "x-go-name": "Start", "description": "Pagination offset", "name": "start", "in": "query" }, { - "type": "integer", - "default": 50, + "type": "string", + "example": "50", + "x-go-name": "Limit", "description": "Limit results", "name": "limit", "in": "query" }, { "type": "string", - "description": "[Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used specifically for `before:` \u0026 `after:` searches (eg: \"Pacific/Auckland\").", + "x-go-name": "TZ", + "description": "[Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` \u0026 `after:` searches (eg: \"Pacific/Auckland\").", "name": "tz", "in": "query" } @@ -562,7 +658,7 @@ "200": { "$ref": "#/responses/MessagesSummaryResponse" }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" } } @@ -580,10 +676,12 @@ "messages" ], "summary": "Delete messages by search", - "operationId": "DeleteSearch", + "operationId": "DeleteSearchParams", "parameters": [ { "type": "string", + "example": "search words", + "x-go-name": "Query", "description": "Search query", "name": "query", "in": "query", @@ -591,7 +689,8 @@ }, { "type": "string", - "description": "[Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used specifically for `before:` \u0026 `after:` searches (eg: \"Pacific/Auckland\").", + "x-go-name": "TZ", + "description": "[Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` \u0026 `after:` searches (eg: \"Pacific/Auckland\").", "name": "tz", "in": "query" } @@ -600,7 +699,7 @@ "200": { "$ref": "#/responses/OKResponse" }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" } } @@ -623,7 +722,7 @@ "message" ], "summary": "Send a message", - "operationId": "SendMessage", + "operationId": "SendMessageParams", "parameters": [ { "name": "Body", @@ -637,7 +736,7 @@ "200": { "$ref": "#/responses/sendMessageResponse" }, - "default": { + "400": { "$ref": "#/responses/jsonErrorResponse" } } @@ -662,7 +761,7 @@ "200": { "$ref": "#/responses/ArrayResponse" }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" } } @@ -683,13 +782,41 @@ "tags" ], "summary": "Set message tags", - "operationId": "SetTags", + "operationId": "SetTagsParams", "parameters": [ { "name": "Body", "in": "body", "schema": { - "$ref": "#/definitions/setTagsRequestBody" + "type": "object", + "required": [ + "Tags", + "IDs" + ], + "properties": { + "IDs": { + "description": "Array of message database IDs", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "4oRBnPtCXgAqZniRhzLNmS", + "hXayS6wnCgNnt6aFTvmOF6" + ] + }, + "Tags": { + "description": "Array of tag names to set", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "Tag 1", + "Tag 2" + ] + } + } } } ], @@ -697,15 +824,15 @@ "200": { "$ref": "#/responses/OKResponse" }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" } } } }, - "/api/v1/tags/{tag}": { + "/api/v1/tags/{Tag}": { "put": { - "description": "Renames a tag.", + "description": "Renames an existing tag.", "produces": [ "text/plain" ], @@ -717,34 +844,45 @@ "tags" ], "summary": "Rename a tag", - "operationId": "RenameTag", + "operationId": "RenameTagParams", "parameters": [ - { - "name": "Body", - "in": "body", - "schema": { - "$ref": "#/definitions/renameTagRequestBody" - } - }, { "type": "string", + "example": "Old name", "description": "The url-encoded tag name to rename", - "name": "tag", + "name": "Tag", "in": "path", "required": true + }, + { + "name": "Body", + "in": "body", + "schema": { + "type": "object", + "required": [ + "Name" + ], + "properties": { + "Name": { + "description": "New name", + "type": "string", + "example": "New name" + } + } + } } ], "responses": { "200": { "$ref": "#/responses/OKResponse" }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" } } }, "delete": { - "description": "Deletes a tag. This will not delete any messages with this tag.", + "description": "Deletes a tag. This will not delete any messages with the tag, but will remove the tag from any messages containing the tag.", "produces": [ "text/plain" ], @@ -756,12 +894,13 @@ "tags" ], "summary": "Delete a tag", - "operationId": "DeleteTag", + "operationId": "DeleteTagParams", "parameters": [ { "type": "string", + "example": "My tag", "description": "The url-encoded tag name to delete", - "name": "tag", + "name": "Tag", "in": "path", "required": true } @@ -770,7 +909,7 @@ "200": { "$ref": "#/responses/OKResponse" }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" } } @@ -795,7 +934,7 @@ "200": { "$ref": "#/responses/WebUIConfigurationResponse" }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" } } @@ -815,11 +954,12 @@ "testing" ], "summary": "Render message HTML part", - "operationId": "GetMessageHTML", + "operationId": "GetMessageHTMLParams", "parameters": [ { "type": "string", - "description": "Database ID or latest", + "example": "4oRBnPtCXgAqZniRhzLNmS", + "description": "Message database ID or \"latest\"", "name": "ID", "in": "path", "required": true @@ -829,8 +969,11 @@ "200": { "$ref": "#/responses/HTMLResponse" }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" + }, + "404": { + "$ref": "#/responses/NotFoundResponse" } } } @@ -849,11 +992,12 @@ "testing" ], "summary": "Render message text part", - "operationId": "GetMessageText", + "operationId": "GetMessageTextParams", "parameters": [ { "type": "string", - "description": "Database ID or latest", + "example": "4oRBnPtCXgAqZniRhzLNmS", + "description": "Message database ID or \"latest\"", "name": "ID", "in": "path", "required": true @@ -863,8 +1007,11 @@ "200": { "$ref": "#/responses/TextResponse" }, - "default": { + "400": { "$ref": "#/responses/ErrorResponse" + }, + "404": { + "$ref": "#/responses/NotFoundResponse" } } } @@ -996,25 +1143,6 @@ }, "x-go-package": "github.com/axllent/mailpit/internal/storage" }, - "DeleteRequest": { - "description": "Delete request", - "type": "object", - "properties": { - "IDs": { - "description": "Array of message database IDs", - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "5dec4247-812e-4b77-9101-e25ad406e9ea", - "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e" - ] - } - }, - "x-go-name": "deleteMessagesRequestBody", - "x-go-package": "github.com/axllent/mailpit/server/apiv1" - }, "HTMLCheckResponse": { "description": "Response represents the HTML check response struct", "type": "object", @@ -1337,7 +1465,7 @@ }, "x-go-package": "github.com/axllent/mailpit/internal/storage" }, - "MessageHeaders": { + "MessageHeadersResponse": { "description": "Message headers", "type": "object", "additionalProperties": { @@ -1503,7 +1631,7 @@ "ID": { "description": "Database ID", "type": "string", - "example": "iAfZVVe2UQFNSG5BAjgYwa" + "example": "iAfZVVe2UQfNSG5BAjgYwa" } }, "x-go-package": "github.com/axllent/mailpit/server/apiv1" @@ -1745,102 +1873,15 @@ }, "x-go-name": "webUIConfiguration", "x-go-package": "github.com/axllent/mailpit/server/apiv1" - }, - "releaseMessageRequestBody": { - "description": "Release request", - "type": "object", - "required": [ - "To" - ], - "properties": { - "To": { - "description": "Array of email addresses to relay the message to", - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "user1@example.com", - "user2@example.com" - ] - } - }, - "x-go-package": "github.com/axllent/mailpit/server/apiv1" - }, - "renameTagRequestBody": { - "description": "Rename tag request", - "type": "object", - "required": [ - "Name" - ], - "properties": { - "Name": { - "description": "New name", - "type": "string", - "example": "New name" - } - }, - "x-go-package": "github.com/axllent/mailpit/server/apiv1" - }, - "setReadStatusRequestBody": { - "description": "Set read status request", - "type": "object", - "properties": { - "IDs": { - "description": "Array of message database IDs", - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "5dec4247-812e-4b77-9101-e25ad406e9ea", - "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e" - ] - }, - "Read": { - "description": "Read status", - "type": "boolean", - "default": false, - "example": true - } - }, - "x-go-package": "github.com/axllent/mailpit/server/apiv1" - }, - "setTagsRequestBody": { - "description": "Set tags request", - "type": "object", - "required": [ - "Tags", - "IDs" - ], - "properties": { - "IDs": { - "description": "Array of message database IDs", - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "5dec4247-812e-4b77-9101-e25ad406e9ea", - "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e" - ] - }, - "Tags": { - "description": "Array of tag names to set", - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "Tag 1", - "Tag 2" - ] - } - }, - "x-go-package": "github.com/axllent/mailpit/server/apiv1" } }, "responses": { + "AppInfoResponse": { + "description": "Application information", + "schema": { + "$ref": "#/definitions/AppInformation" + } + }, "ArrayResponse": { "description": "Plain JSON array response", "schema": { @@ -1851,13 +1892,13 @@ } }, "BinaryResponse": { - "description": "Binary data response inherits the attachment's content type.", + "description": "Binary data response which inherits the attachment's content type.", "schema": { "type": "string" } }, "ErrorResponse": { - "description": "HTTP error response will return with a \u003e= 400 response code", + "description": "Server error will return with a 400 status code\nwith the error message in the body", "schema": { "type": "string" } @@ -1868,16 +1909,16 @@ "type": "string" } }, - "InfoResponse": { - "description": "Application information", + "MessagesSummaryResponse": { + "description": "Summary of messages", "schema": { - "$ref": "#/definitions/AppInformation" + "$ref": "#/definitions/MessagesSummary" } }, - "MessagesSummaryResponse": { - "description": "Message summary", + "NotFoundResponse": { + "description": "Not found error will return a 404 status code", "schema": { - "$ref": "#/definitions/MessagesSummary" + "type": "string" } }, "OKResponse": { @@ -1893,7 +1934,7 @@ } }, "WebUIConfigurationResponse": { - "description": "Web UI configuration", + "description": "Web UI configuration response", "schema": { "$ref": "#/definitions/WebUIConfiguration" }