diff --git a/README.md b/README.md index 2f2b608..43b9d05 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster. - Optional SMTP with STARTTLS & SMTP authentication ([see wiki](https://github.com/axllent/mailpit/wiki/SMTP-with-STARTTLS-and-authentication)) - Optional HTTPS for web UI ([see wiki](https://github.com/axllent/mailpit/wiki/HTTPS)) - Optional basic authentication for web UI ([see wiki](https://github.com/axllent/mailpit/wiki/Basic-authentication)) +- A simple REST API allowing ([see docs](docs/apiv1/README.md)) - Multi-architecture [Docker images](https://github.com/axllent/mailpit/wiki/Docker-images) diff --git a/config/config.go b/config/config.go index ed199d3..5fc906f 100644 --- a/config/config.go +++ b/config/config.go @@ -55,6 +55,9 @@ var ( // SMTPAuth used for euthentication SMTPAuth *htpasswd.File + + // ContentSecurityPolicy for HTTP server + ContentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src 'self' data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';" ) // VerifyConfig wil do some basic checking diff --git a/data/message.go b/data/message.go index da6d6d1..a6cf788 100644 --- a/data/message.go +++ b/data/message.go @@ -20,7 +20,6 @@ type Message struct { Date time.Time Text string HTML string - HTMLSource string Size int Inline []Attachment Attachments []Attachment diff --git a/docs/apiv1/Message.md b/docs/apiv1/Message.md new file mode 100644 index 0000000..064e4ad --- /dev/null +++ b/docs/apiv1/Message.md @@ -0,0 +1,80 @@ +# Message + +Returns a summary of the message and attachments. + +**URL** : `api/v1/message/` + +**Method** : `GET` + +## Response + +**Status** : `200` + +```json +{ + "ID": "d7a5543b-96dd-478b-9b60-2b465c9884de", + "Read": true, + "From": { + "Name": "John Doe", + "Address": "john@example.com" + }, + "To": [ + { + "Name": "Jane Smith", + "Address": "jane@example.com" + } + ], + "Cc": null, + "Bcc": null, + "Subject": "Message subject", + "Date": "2016-09-07T16:46:00+13:00", + "Text": "Plain text MIME part of the email", + "HTML": "HTML MIME part (if exists)", + "Size": 79499, + "Inline": [ + { + "PartID": "1.2", + "FileName": "filename.gif", + "ContentType": "image/gif", + "ContentID": "919564503@07092006-1525", + "Size": 7760 + } + ], + "Attachments": [ + { + "PartID": "2", + "FileName": "filename.doc", + "ContentType": "application/msword", + "ContentID": "", + "Size": 43520 + } + ] +} +``` +### Notes + +- `Read` - always true (message marked read on open) +- `From` - Name & Address, or null +- `To`, `CC`, `BCC` - Array of Names & Address, or null +- `Date` - Parsed email local date & time from headers +- `Size` - Total size of raw email +- `Inline`, `Attachments` - Array of attachments and inline images. + + +--- +## Attachments + +**URL** : `api/v1/message//part/` + +**Method** : `GET` + +Returns the attachment using the MIME type provided by the attachment `ContentType`. + +--- +## Raw (source) email + +**URL** : `api/v1/message//raw` + +**Method** : `GET` + +Returns the original email source including headers and attachments. diff --git a/docs/apiv1/Messages.md b/docs/apiv1/Messages.md new file mode 100644 index 0000000..a4d5aea --- /dev/null +++ b/docs/apiv1/Messages.md @@ -0,0 +1,166 @@ +# Messages + +List & delete messages. + + +--- +## List + +List messages in the mailbox. Messages are returned in the order of latest received to oldest. + +**URL** : `api/v1/messages` + +**Method** : `GET` + + +### Query parameters + +| Parameter | Type | Required | Description | +|-----------|---------|----------|----------------------------| +| limit | integer | false | Limit results (default 50) | +| start | integer | false | Pagination offset | + + +### Response + +**Status** : `200` + +```json +{ + "total": 500, + "unread": 500, + "count": 50, + "start": 0, + "messages": [ + { + "ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f", + "Read": false, + "From": { + "Name": "John Doe", + "Address": "john@example.com" + }, + "To": [ + { + "Name": "Jane Smith", + "Address": "jane@example.com" + } + ], + "Cc": [ + { + "Name": "Accounts", + "Address": "accounts@example.com" + } + ], + "Bcc": null, + "Subject": "Message subject", + "Created": "2022-10-03T21:35:32.228605299+13:00", + "Size": 6144, + "Attachments": 0 + }, + ... + ] +} +``` + +### Notes + +- `total` - Total messages in mailbox +- `unread` - Total unread messages in mailbox +- `count` - Number of messages returned in request +- `start` - The offset (default `0`) for pagination +- `Read` - The read/unread status of the message +- `From` - Name & Address, or null if none +- `To`, `CC`, `BCC` - Array of Names & Address, or null if none +- `Created` - Local date & time the message was received +- `Size` - Total size of raw email in bytes + + +--- +## Delete individual messages + +Delete one or more messages by ID. + +**URL** : `api/v1/messages` + +**Method** : `DELETE` + +### Request + +```json +{ + "ids": ["",""...] +} +``` + +### Response + +**Status** : `200` + + +--- +## Delete all messages + +Delete all messages (same as deleting individual messages, but with the "ids" either empty or omitted entirely). + +**URL** : `api/v1/messages` + +**Method** : `DELETE` + +### Request + +```json +{ + "ids": [] +} +``` + +### Response + +**Status** : `200` + + +--- +## Update individual read statuses + +Set the read status of one or more messages. +The `read` status can be `true` or `false`. + +**URL** : `api/v1/messages` + +**Method** : `PUT` + +### Request + +```json +{ + "ids": ["",""...], + "read": false +} +``` + +### Response + +**Status** : `200` + +--- +## Update all messages read status + +Set the read status of all messages. +The `read` status can be `true` or `false`. + +**URL** : `api/v1/messages` + +**Method** : `PUT` + +### Request + +```json +{ + "ids": [], + "read": false +} +``` + +### Response + +**Status** : `200` diff --git a/docs/apiv1/README.md b/docs/apiv1/README.md new file mode 100644 index 0000000..3b6a133 --- /dev/null +++ b/docs/apiv1/README.md @@ -0,0 +1,11 @@ +# API v1 + +Mailpit provides a simple REST API to access and delete stored messages. + +If the Mailpit server is set to use Basic Authentication, then API requests must use Basic Authentication too. + +The API is split into three main parts: + +- [Messages](Messages.md) - Listing, deleting & marking messages as read/unread. +- [Message](Message.md) - Return message data & attachments +- [Search](Search.md) - Searching messages diff --git a/docs/apiv1/Search.md b/docs/apiv1/Search.md new file mode 100644 index 0000000..58cb5cf --- /dev/null +++ b/docs/apiv1/Search.md @@ -0,0 +1,67 @@ +# Search + +**URL** : `api/v1/search?query=` + +**Method** : `GET` + +The search returns up to 200 of the most recent matches, and does not support pagination or limits. +Matching messages are returned in the order of latest received to oldest. + + +## Query parameters + +| Parameter | Type | Required | Description | +|-----------|--------|----------|--------------| +| query | string | true | Search query | + + +## Response + +**Status** : `200` + +```json +{ + "total": 500, + "unread": 500, + "count": 25, + "start": 0, + "messages": [ + { + "ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f", + "Read": false, + "From": { + "Name": "John Doe", + "Address": "john@example.com" + }, + "To": [ + { + "Name": "Jane Smith", + "Address": "jane@example.com" + } + ], + "Cc": [ + { + "Name": "Accounts", + "Address": "accounts@example.com" + } + ], + "Bcc": null, + "Subject": "Test email", + "Created": "2022-10-03T21:35:32.228605299+13:00", + "Size": 6144, + "Attachments": 0 + }, + ... + ] +} +``` + +### Notes + +- `total` - Total messages in mailbox (all messages, not search) +- `unread` - Total unread messages in mailbox (all messages, not search) +- `count` - Number of messages returned in request (up to 200 for search) +- `start` - Always 0 (offset in search is unsupported) +- `From` - Singular Name & Address, or null if none +- `To`, `CC`, `BCC` - Array of Name & Address, or null if none +- `Size` - Total size of raw email in bytes diff --git a/server/api.go b/server/api.go deleted file mode 100644 index 09717a4..0000000 --- a/server/api.go +++ /dev/null @@ -1,279 +0,0 @@ -package server - -import ( - "encoding/json" - "net/http" - "strings" - - "github.com/axllent/mailpit/data" - "github.com/axllent/mailpit/server/websockets" - "github.com/axllent/mailpit/storage" - "github.com/gorilla/mux" -) - -type messagesResult struct { - Total int `json:"total"` - Unread int `json:"unread"` - Count int `json:"count"` - Start int `json:"start"` - Items []data.Summary `json:"items"` -} - -// Return a list of available mailboxes -func apiMailboxStats(w http.ResponseWriter, _ *http.Request) { - res := storage.StatsGet() - - bytes, _ := json.Marshal(res) - w.Header().Add("Content-Type", "application/json") - _, _ = w.Write(bytes) -} - -// List messages -func apiListMessages(w http.ResponseWriter, r *http.Request) { - start, limit := getStartLimit(r) - - messages, err := storage.List(start, limit) - if err != nil { - httpError(w, err.Error()) - return - } - - stats := storage.StatsGet() - - var res messagesResult - - res.Start = start - res.Items = messages - res.Count = len(res.Items) - res.Total = stats.Total - res.Unread = stats.Unread - - bytes, _ := json.Marshal(res) - w.Header().Add("Content-Type", "application/json") - _, _ = w.Write(bytes) -} - -// Search all messages -func apiSearchMessages(w http.ResponseWriter, r *http.Request) { - search := strings.TrimSpace(r.URL.Query().Get("query")) - if search == "" { - fourOFour(w) - return - } - - messages, err := storage.Search(search) - if err != nil { - httpError(w, err.Error()) - return - } - - stats := storage.StatsGet() - - var res messagesResult - - res.Start = 0 - res.Items = messages - res.Count = len(messages) - res.Total = stats.Total - res.Unread = stats.Unread - - bytes, _ := json.Marshal(res) - w.Header().Add("Content-Type", "application/json") - _, _ = w.Write(bytes) -} - -// Open a message -func apiOpenMessage(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - - id := vars["id"] - - msg, err := storage.GetMessage(id) - if err != nil { - httpError(w, "Message not found") - return - } - - bytes, _ := json.Marshal(msg) - w.Header().Add("Content-Type", "application/json") - _, _ = w.Write(bytes) -} - -// Download/view an attachment -func apiDownloadAttachment(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - - id := vars["id"] - partID := vars["partID"] - - a, err := storage.GetAttachmentPart(id, partID) - if err != nil { - httpError(w, err.Error()) - 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) -} - -// Download the full email source as plain text -func apiDownloadRaw(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - - id := vars["id"] - - dl := r.FormValue("dl") - - data, err := storage.GetMessageRaw(id) - if err != nil { - httpError(w, err.Error()) - return - } - - w.Header().Set("Content-Type", "text/plain") - if dl == "1" { - w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"") - } - _, _ = w.Write(data) -} - -// Delete all messages -func apiDeleteAll(w http.ResponseWriter, r *http.Request) { - err := storage.DeleteAllMessages() - if err != nil { - httpError(w, err.Error()) - return - } - - w.Header().Add("Content-Type", "text/plain") - _, _ = w.Write([]byte("ok")) -} - -// Delete all selected messages -func apiDeleteSelected(w http.ResponseWriter, r *http.Request) { - decoder := json.NewDecoder(r.Body) - - var data struct { - IDs []string - } - err := decoder.Decode(&data) - if err != nil { - panic(err) - } - - ids := data.IDs - - for _, id := range ids { - if err := storage.DeleteOneMessage(id); err != nil { - httpError(w, err.Error()) - return - } - } - - w.Header().Add("Content-Type", "text/plain") - _, _ = w.Write([]byte("ok")) -} - -// Delete a single message -func apiDeleteOne(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - - id := vars["id"] - - err := storage.DeleteOneMessage(id) - if err != nil { - httpError(w, err.Error()) - return - } - - w.Header().Add("Content-Type", "text/plain") - _, _ = w.Write([]byte("ok")) -} - -// Mark single message as unread -func apiUnreadOne(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - - id := vars["id"] - - err := storage.MarkUnread(id) - if err != nil { - httpError(w, err.Error()) - return - } - - w.Header().Add("Content-Type", "text/plain") - _, _ = w.Write([]byte("ok")) -} - -// Mark all messages as read -func apiMarkAllRead(w http.ResponseWriter, r *http.Request) { - err := storage.MarkAllRead() - if err != nil { - httpError(w, err.Error()) - return - } - - w.Header().Add("Content-Type", "text/plain") - _, _ = w.Write([]byte("ok")) -} - -// Mark selected message as read -func apiMarkSelectedRead(w http.ResponseWriter, r *http.Request) { - decoder := json.NewDecoder(r.Body) - - var data struct { - IDs []string - } - err := decoder.Decode(&data) - if err != nil { - panic(err) - } - - ids := data.IDs - - for _, id := range ids { - if err := storage.MarkRead(id); err != nil { - httpError(w, err.Error()) - return - } - } - - w.Header().Add("Content-Type", "text/plain") - _, _ = w.Write([]byte("ok")) -} - -// Mark selected message as unread -func apiMarkSelectedUnread(w http.ResponseWriter, r *http.Request) { - decoder := json.NewDecoder(r.Body) - - var data struct { - IDs []string - } - err := decoder.Decode(&data) - if err != nil { - panic(err) - } - - ids := data.IDs - - 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")) -} - -// Websocket to broadcast changes -func apiWebsocket(w http.ResponseWriter, r *http.Request) { - websockets.ServeWs(websockets.MessageHub, w, r) -} diff --git a/server/apiv1/api.go b/server/apiv1/api.go new file mode 100644 index 0000000..044b7b1 --- /dev/null +++ b/server/apiv1/api.go @@ -0,0 +1,289 @@ +package apiv1 + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/axllent/mailpit/config" + "github.com/axllent/mailpit/data" + "github.com/axllent/mailpit/storage" + "github.com/gorilla/mux" +) + +// MessagesResult struct +type MessagesResult struct { + Total int `json:"total"` + Unread int `json:"unread"` + Count int `json:"count"` + Start int `json:"start"` + Messages []data.Summary `json:"messages"` +} + +// // Mailbox returns an message overview (stats) +// func Mailbox(w http.ResponseWriter, _ *http.Request) { +// res := storage.StatsGet() + +// bytes, _ := json.Marshal(res) +// w.Header().Add("Content-Type", "application/json") +// _, _ = w.Write(bytes) +// } + +// Messages returns a paginated list of messages +func Messages(w http.ResponseWriter, r *http.Request) { + start, limit := getStartLimit(r) + + messages, err := storage.List(start, limit) + if err != nil { + httpError(w, err.Error()) + return + } + + stats := storage.StatsGet() + + var res MessagesResult + + res.Start = start + res.Messages = messages + res.Count = len(messages) + res.Total = stats.Total + res.Unread = stats.Unread + + bytes, _ := json.Marshal(res) + w.Header().Add("Content-Type", "application/json") + _, _ = w.Write(bytes) +} + +// Search returns a max of 200 of the latest messages +func Search(w http.ResponseWriter, r *http.Request) { + search := strings.TrimSpace(r.URL.Query().Get("query")) + if search == "" { + fourOFour(w) + return + } + + messages, err := storage.Search(search) + if err != nil { + httpError(w, err.Error()) + return + } + + stats := storage.StatsGet() + + var res MessagesResult + + res.Start = 0 + res.Messages = messages + res.Count = len(messages) + res.Total = stats.Total + res.Unread = stats.Unread + + bytes, _ := json.Marshal(res) + w.Header().Add("Content-Type", "application/json") + _, _ = w.Write(bytes) +} + +// Message (method: GET) returns a *data.Message +func Message(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + + id := vars["id"] + + msg, err := storage.GetMessage(id) + if err != nil { + httpError(w, "Message not found") + return + } + + bytes, _ := json.Marshal(msg) + w.Header().Add("Content-Type", "application/json") + _, _ = w.Write(bytes) +} + +// DownloadAttachment (method: GET) returns the attachment data +func DownloadAttachment(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + + id := vars["id"] + partID := vars["partID"] + + a, err := storage.GetAttachmentPart(id, partID) + if err != nil { + httpError(w, err.Error()) + 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) +} + +// DownloadRaw (method: GET) returns the full email source as plain text +func DownloadRaw(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + + id := vars["id"] + + dl := r.FormValue("dl") + + data, err := storage.GetMessageRaw(id) + if err != nil { + httpError(w, err.Error()) + return + } + + w.Header().Set("Content-Type", "text/plain") + if dl == "1" { + w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"") + } + _, _ = w.Write(data) +} + +// DeleteMessages (method: DELETE) deletes all messages matching IDS. +// If no IDs are provided then all messages are deleted. +func DeleteMessages(w http.ResponseWriter, r *http.Request) { + 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 { + for _, id := range data.IDs { + if err := storage.DeleteOneMessage(id); err != nil { + httpError(w, err.Error()) + return + } + } + } + + w.Header().Add("Content-Type", "text/plain") + _, _ = w.Write([]byte("ok")) +} + +// // DeleteMessage (method: DELETE) deletes a single message +// func DeleteMessage(w http.ResponseWriter, r *http.Request) { +// vars := mux.Vars(r) + +// id := vars["id"] + +// err := storage.DeleteOneMessage(id) +// if err != nil { +// httpError(w, err.Error()) +// return +// } + +// w.Header().Add("Content-Type", "text/plain") +// _, _ = w.Write([]byte("ok")) +// } + +// SetAllRead (GET) will update all messages as read +// func SetAllRead(w http.ResponseWriter, r *http.Request) { +// err := storage.MarkAllRead() +// if 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 +func SetReadStatus(w http.ResponseWriter, r *http.Request) { + 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")) +} + +// FourOFour returns a basic 404 message +func fourOFour(w http.ResponseWriter) { + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy) + w.WriteHeader(http.StatusNotFound) + w.Header().Set("Content-Type", "text/plain") + fmt.Fprint(w, "404 page not found") +} + +// HTTPError returns a basic error message (400 response) +func httpError(w http.ResponseWriter, msg string) { + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy) + w.WriteHeader(http.StatusBadRequest) + w.Header().Set("Content-Type", "text/plain") + fmt.Fprint(w, msg) +} + +// Get the start and limit based on query params. Defaults to 0, 50 +func getStartLimit(req *http.Request) (start int, limit int) { + start = 0 + limit = 50 + + s := req.URL.Query().Get("start") + if n, err := strconv.Atoi(s); err == nil && n > 0 { + start = n + } + + l := req.URL.Query().Get("limit") + if n, err := strconv.Atoi(l); err == nil && n > 0 { + limit = n + } + + return start, limit +} diff --git a/server/thumbnails.go b/server/apiv1/thumbnails.go similarity index 94% rename from server/thumbnails.go rename to server/apiv1/thumbnails.go index 741f366..d77dec7 100644 --- a/server/thumbnails.go +++ b/server/apiv1/thumbnails.go @@ -1,4 +1,4 @@ -package server +package apiv1 import ( "bufio" @@ -22,8 +22,8 @@ var ( thumbHeight = 120 ) -// Attachment thumbnail (images only) -func apiAttachmentThumbnail(w http.ResponseWriter, r *http.Request) { +// Thumbnail returns a thumbnail image for an attachment (images only) +func Thumbnail(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] diff --git a/server/server.go b/server/server.go index 93be26f..192160d 100644 --- a/server/server.go +++ b/server/server.go @@ -3,17 +3,16 @@ package server import ( "compress/gzip" "embed" - "fmt" "io" "io/fs" "log" "net/http" "os" - "strconv" "strings" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/logger" + "github.com/axllent/mailpit/server/apiv1" "github.com/axllent/mailpit/server/websockets" "github.com/gorilla/mux" ) @@ -21,8 +20,6 @@ import ( //go:embed ui var embeddedFS embed.FS -var contentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src 'self' data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';" - // Listen will start the httpd func Listen() { serverRoot, err := fs.Sub(embeddedFS, "ui") @@ -35,22 +32,12 @@ func Listen() { go websockets.MessageHub.Run() - r := mux.NewRouter() - r.HandleFunc("/api/stats", middleWareFunc(apiMailboxStats)).Methods("GET") - r.HandleFunc("/api/messages", middleWareFunc(apiListMessages)).Methods("GET") - r.HandleFunc("/api/search", middleWareFunc(apiSearchMessages)).Methods("GET") - r.HandleFunc("/api/delete", middleWareFunc(apiDeleteAll)).Methods("GET") - r.HandleFunc("/api/delete", middleWareFunc(apiDeleteSelected)).Methods("POST") + r := defaultRoutes() + + // web UI websocket r.HandleFunc("/api/events", apiWebsocket).Methods("GET") - r.HandleFunc("/api/read", apiMarkAllRead).Methods("GET") - r.HandleFunc("/api/read", apiMarkSelectedRead).Methods("POST") - r.HandleFunc("/api/unread", apiMarkSelectedUnread).Methods("POST") - r.HandleFunc("/api/{id}/raw", middleWareFunc(apiDownloadRaw)).Methods("GET") - r.HandleFunc("/api/{id}/part/{partID}", middleWareFunc(apiDownloadAttachment)).Methods("GET") - r.HandleFunc("/api/{id}/part/{partID}/thumb", middleWareFunc(apiAttachmentThumbnail)).Methods("GET") - r.HandleFunc("/api/{id}/delete", middleWareFunc(apiDeleteOne)).Methods("GET") - r.HandleFunc("/api/{id}/unread", middleWareFunc(apiUnreadOne)).Methods("GET") - r.HandleFunc("/api/{id}", middleWareFunc(apiOpenMessage)).Methods("GET") + + // virtual filesystem for others r.PathPrefix("/").Handler(middlewareHandler(http.FileServer(http.FS(serverRoot)))) http.Handle("/", r) @@ -67,6 +54,22 @@ func Listen() { } } +func defaultRoutes() *mux.Router { + r := mux.NewRouter() + + // API V1 + r.HandleFunc("/api/v1/messages", middleWareFunc(apiv1.Messages)).Methods("GET") + r.HandleFunc("/api/v1/messages", middleWareFunc(apiv1.SetReadStatus)).Methods("PUT") + r.HandleFunc("/api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE") + r.HandleFunc("/api/v1/search", middleWareFunc(apiv1.Search)).Methods("GET") + r.HandleFunc("/api/v1/message/{id}/raw", middleWareFunc(apiv1.DownloadRaw)).Methods("GET") + r.HandleFunc("/api/v1/message/{id}/part/{partID}", middleWareFunc(apiv1.DownloadAttachment)).Methods("GET") + r.HandleFunc("/api/v1/message/{id}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail)).Methods("GET") + r.HandleFunc("/api/v1/message/{id}", middleWareFunc(apiv1.Message)).Methods("GET") + + return r +} + // BasicAuthResponse returns an basic auth response to the browser func basicAuthResponse(w http.ResponseWriter) { w.Header().Set("WWW-Authenticate", `Basic realm="Login"`) @@ -88,7 +91,7 @@ func (w gzipResponseWriter) Write(b []byte) (int, error) { func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Referrer-Policy", "no-referrer") - w.Header().Set("Content-Security-Policy", contentSecurityPolicy) + w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy) if config.UIAuthFile != "" { user, pass, ok := r.BasicAuth() @@ -121,7 +124,7 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc { func middlewareHandler(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Referrer-Policy", "no-referrer") - w.Header().Set("Content-Security-Policy", contentSecurityPolicy) + w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy) if config.UIAuthFile != "" { user, pass, ok := r.BasicAuth() @@ -148,38 +151,7 @@ func middlewareHandler(h http.Handler) http.Handler { }) } -// FourOFour returns a basic 404 message -func fourOFour(w http.ResponseWriter) { - w.Header().Set("Referrer-Policy", "no-referrer") - w.Header().Set("Content-Security-Policy", contentSecurityPolicy) - w.WriteHeader(http.StatusNotFound) - w.Header().Set("Content-Type", "text/plain") - fmt.Fprint(w, "404 page not found") -} - -// HTTPError returns a basic error message (400 response) -func httpError(w http.ResponseWriter, msg string) { - w.Header().Set("Referrer-Policy", "no-referrer") - w.Header().Set("Content-Security-Policy", contentSecurityPolicy) - w.WriteHeader(http.StatusBadRequest) - w.Header().Set("Content-Type", "text/plain") - fmt.Fprint(w, msg) -} - -// Get the start and limit based on query params. Defaults to 0, 50 -func getStartLimit(req *http.Request) (start int, limit int) { - start = 0 - limit = 50 - - s := req.URL.Query().Get("start") - if n, err := strconv.Atoi(s); err == nil && n > 0 { - start = n - } - - l := req.URL.Query().Get("limit") - if n, err := strconv.Atoi(l); err == nil && n > 0 { - limit = n - } - - return start, limit +// Websocket to broadcast changes +func apiWebsocket(w http.ResponseWriter, r *http.Request) { + websockets.ServeWs(websockets.MessageHub, w, r) } diff --git a/storage/database.go b/storage/database.go index 0cff82a..3e795f3 100644 --- a/storage/database.go +++ b/storage/database.go @@ -370,17 +370,16 @@ func GetMessage(id string) (*data.Message, error) { date, _ := env.Date() obj := data.Message{ - ID: id, - Read: true, - From: from, - Date: date, - To: addressToSlice(env, "To"), - Cc: addressToSlice(env, "Cc"), - Bcc: addressToSlice(env, "Bcc"), - Subject: env.GetHeader("Subject"), - Size: len(raw), - Text: env.Text, - HTMLSource: env.HTML, + ID: id, + Read: true, + From: from, + Date: date, + To: addressToSlice(env, "To"), + Cc: addressToSlice(env, "Cc"), + Bcc: addressToSlice(env, "Bcc"), + Subject: env.GetHeader("Subject"), + Size: len(raw), + Text: env.Text, } html := env.HTML @@ -388,6 +387,7 @@ func GetMessage(id string) (*data.Message, error) { // strip base tags var re = regexp.MustCompile(`(?U)`) html = re.ReplaceAllString(html, "") + obj.HTML = html for _, i := range env.Inlines { if i.FileName != "" || i.ContentID != "" { @@ -407,8 +407,6 @@ func GetMessage(id string) (*data.Message, error) { } } - obj.HTML = html - // mark message as read if err := MarkRead(id); err != nil { return &obj, err @@ -511,6 +509,7 @@ func MarkAllRead() error { _, err := sqlf.Update("mailbox"). Set("Read", 1). + Where("Read = ?", 0). ExecAndClose(context.Background(), db) if err != nil { return err @@ -524,6 +523,29 @@ func MarkAllRead() error { return nil } +// MarkAllUnread will mark all messages as unread +func MarkAllUnread() error { + var ( + start = time.Now() + total = CountRead() + ) + + _, err := sqlf.Update("mailbox"). + Set("Read", 0). + Where("Read = ?", 1). + ExecAndClose(context.Background(), db) + if err != nil { + return err + } + + elapsed := time.Since(start) + logger.Log().Debugf("[db] marked %d messages as unread in %s", total, elapsed) + + dbLastAction = time.Now() + + return nil +} + // MarkUnread will mark a message as unread func MarkUnread(id string) error { if IsUnread(id) { @@ -655,7 +677,6 @@ func CountTotal() int { } // CountUnread returns the number of emails in the database that are unread. -// If an ID is supplied, then it is just limited to that message. func CountUnread() int { var total int @@ -668,6 +689,19 @@ func CountUnread() int { return total } +// CountRead returns the number of emails in the database that are read. +func CountRead() int { + var total int + + q := sqlf.From("mailbox"). + Select("COUNT(*)").To(&total). + Where("Read = ?", 1) + + _ = q.QueryRowAndClose(nil, db) + + return total +} + // IsUnread returns the number of emails in the database that are unread. // If an ID is supplied, then it is just limited to that message. func IsUnread(id string) bool {