1
0
mirror of https://github.com/axllent/mailpit.git synced 2025-04-15 11:56:44 +02:00

Merge branch 'release/1.2.2'

This commit is contained in:
Ralph Slooten 2022-10-13 08:14:42 +13:00
commit a2b6107dd6
11 changed files with 127 additions and 69 deletions

View File

@ -4,9 +4,15 @@ Notable changes to Mailpit will be documented in this file.
## 1.2.2 ## 1.2.2
### API
- Add API endpoint to return message headers
### Libs ### Libs
- Update go modules - Update go modules
### Testing
- Add API test for raw & message headers
## 1.2.1 ## 1.2.1

View File

@ -1,24 +0,0 @@
package data
import "time"
// MailboxSummary struct
type MailboxSummary struct {
Name string
Slug string
Total int
Unread int
LastMessage time.Time
}
// WebsocketNotification struct for responses
type WebsocketNotification struct {
Type string
Data interface{}
}
// MailboxStats struct for quick mailbox total/read lookups
type MailboxStats struct {
Total int
Unread int
}

View File

@ -1,6 +1,8 @@
# Message # Message
Returns a summary of the message and attachments. ## Message summary
Returns a JSON summary of the message and attachments.
**URL** : `api/v1/message/<ID>` **URL** : `api/v1/message/<ID>`
@ -70,6 +72,39 @@ Returns a summary of the message and attachments.
Returns the attachment using the MIME type provided by the attachment `ContentType`. Returns the attachment using the MIME type provided by the attachment `ContentType`.
---
## Headers
**URL** : `api/v1/message/<ID>/headers`
**Method** : `GET`
Returns all message headers as a JSON array.
Each unique header key contains an array of one or more values (email headers can be listed multiple times.)
```json
{
"Content-Type": [
"multipart/related; type=\"multipart/alternative\"; boundary=\"----=_NextPart_000_0013_01C6A60C.47EEAB80\""
],
"Date": [
"Wed, 12 Jul 2006 23:38:30 +1200"
],
"Delivered-To": [
"user@example.com",
"user-alias@example.com"
],
"From": [
"\"User Name\" \\u003remote@example.com\\u003e"
],
"Message-Id": [
"\\u003c001701c6a5a7$b3205580$0201010a@HomeOfficeSM\\u003e"
],
....
}
```
--- ---
## Raw (source) email ## Raw (source) email

View File

@ -8,12 +8,12 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"log"
"net/mail" "net/mail"
"net/smtp" "net/smtp"
"os" "os"
"os/user" "os/user"
"github.com/axllent/mailpit/logger"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
) )
@ -80,6 +80,6 @@ func Run() {
err = smtp.SendMail(smtpAddr, nil, fromAddr, recip, body) err = smtp.SendMail(smtpAddr, nil, fromAddr, recip, body)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "error sending mail") fmt.Fprintln(os.Stderr, "error sending mail")
log.Fatal(err) logger.Log().Fatal(err)
} }
} }

View File

@ -4,11 +4,11 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/mail"
"strconv" "strconv"
"strings" "strings"
"github.com/axllent/mailpit/config" "github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/data"
"github.com/axllent/mailpit/storage" "github.com/axllent/mailpit/storage"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@ -19,10 +19,10 @@ type MessagesResult struct {
Unread int `json:"unread"` Unread int `json:"unread"`
Count int `json:"count"` Count int `json:"count"`
Start int `json:"start"` Start int `json:"start"`
Messages []data.Summary `json:"messages"` Messages []storage.Summary `json:"messages"`
} }
// Messages returns a paginated list of messages // Messages returns a paginated list of messages as JSON
func Messages(w http.ResponseWriter, r *http.Request) { func Messages(w http.ResponseWriter, r *http.Request) {
start, limit := getStartLimit(r) start, limit := getStartLimit(r)
@ -47,7 +47,7 @@ func Messages(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(bytes) _, _ = w.Write(bytes)
} }
// Search returns a max of 200 of the latest messages // Search returns up to 200 of the latest messages as JSON
func Search(w http.ResponseWriter, r *http.Request) { func Search(w http.ResponseWriter, r *http.Request) {
search := strings.TrimSpace(r.URL.Query().Get("query")) search := strings.TrimSpace(r.URL.Query().Get("query"))
if search == "" { if search == "" {
@ -76,7 +76,7 @@ func Search(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(bytes) _, _ = w.Write(bytes)
} }
// Message (method: GET) returns a *data.Message // Message (method: GET) returns the *data.Message as JSON
func Message(w http.ResponseWriter, r *http.Request) { func Message(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
@ -115,6 +115,32 @@ func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(a.Content) _, _ = w.Write(a.Content)
} }
// Headers (method: GET) returns the message headers as JSON
func Headers(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
data, err := storage.GetMessageRaw(id)
if err != nil {
httpError(w, err.Error())
return
}
reader := strings.NewReader(string(data))
m, err := mail.ReadMessage(reader)
if err != nil {
httpError(w, err.Error())
return
}
headers := m.Header
bytes, _ := json.Marshal(headers)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// DownloadRaw (method: GET) returns the full email source as plain text // DownloadRaw (method: GET) returns the full email source as plain text
func DownloadRaw(w http.ResponseWriter, r *http.Request) { func DownloadRaw(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)

View File

@ -5,7 +5,6 @@ import (
"embed" "embed"
"io" "io"
"io/fs" "io/fs"
"log"
"net/http" "net/http"
"os" "os"
"strings" "strings"
@ -47,10 +46,10 @@ func Listen() {
if config.UISSLCert != "" && config.UISSLKey != "" { if config.UISSLCert != "" && config.UISSLKey != "" {
logger.Log().Infof("[http] starting secure server on https://%s", config.HTTPListen) logger.Log().Infof("[http] starting secure server on https://%s", config.HTTPListen)
log.Fatal(http.ListenAndServeTLS(config.HTTPListen, config.UISSLCert, config.UISSLKey, nil)) logger.Log().Fatal(http.ListenAndServeTLS(config.HTTPListen, config.UISSLCert, config.UISSLKey, nil))
} else { } else {
logger.Log().Infof("[http] starting server on http://%s", config.HTTPListen) logger.Log().Infof("[http] starting server on http://%s", config.HTTPListen)
log.Fatal(http.ListenAndServe(config.HTTPListen, nil)) logger.Log().Fatal(http.ListenAndServe(config.HTTPListen, nil))
} }
} }
@ -62,9 +61,10 @@ func defaultRoutes() *mux.Router {
r.HandleFunc("/api/v1/messages", middleWareFunc(apiv1.SetReadStatus)).Methods("PUT") r.HandleFunc("/api/v1/messages", middleWareFunc(apiv1.SetReadStatus)).Methods("PUT")
r.HandleFunc("/api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE") r.HandleFunc("/api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE")
r.HandleFunc("/api/v1/search", middleWareFunc(apiv1.Search)).Methods("GET") 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}", 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}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail)).Methods("GET")
r.HandleFunc("/api/v1/message/{id}/raw", middleWareFunc(apiv1.DownloadRaw)).Methods("GET")
r.HandleFunc("/api/v1/message/{id}/headers", middleWareFunc(apiv1.Headers)).Methods("GET")
r.HandleFunc("/api/v1/message/{id}", middleWareFunc(apiv1.Message)).Methods("GET") r.HandleFunc("/api/v1/message/{id}", middleWareFunc(apiv1.Message)).Methods("GET")
r.HandleFunc("/api/v1/info", middleWareFunc(apiv1.AppInfo)).Methods("GET") r.HandleFunc("/api/v1/info", middleWareFunc(apiv1.AppInfo)).Methods("GET")

View File

@ -54,15 +54,24 @@ func Test_APIv1(t *testing.T) {
} }
// read first 10 // read first 10
t.Log("Read first 10 messages") t.Log("Read first 10 messages including raw & headers")
putIDS := []string{} putIDS := []string{}
for indx, msg := range m.Messages { for indx, msg := range m.Messages {
if indx == 10 { if indx == 10 {
break break
} }
_, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID) if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID); err != nil {
if err != nil { t.Errorf(err.Error())
}
// test RAW
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/raw"); err != nil {
t.Errorf(err.Error())
}
// test headers
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/headers"); err != nil {
t.Errorf(err.Error()) t.Errorf(err.Error())
} }

View File

@ -5,11 +5,11 @@
package websockets package websockets
import ( import (
"log"
"net/http" "net/http"
"time" "time"
"github.com/axllent/mailpit/config" "github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/logger"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@ -117,7 +117,7 @@ func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil) conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
log.Println(err) logger.Log().Error(err)
return return
} }

View File

@ -7,7 +7,6 @@ package websockets
import ( import (
"encoding/json" "encoding/json"
"github.com/axllent/mailpit/data"
"github.com/axllent/mailpit/logger" "github.com/axllent/mailpit/logger"
) )
@ -27,6 +26,12 @@ type Hub struct {
unregister chan *Client unregister chan *Client
} }
// WebsocketNotification struct for responses
type WebsocketNotification struct {
Type string
Data interface{}
}
// NewHub returns a new hub configuration // NewHub returns a new hub configuration
func NewHub() *Hub { func NewHub() *Hub {
return &Hub{ return &Hub{
@ -68,7 +73,7 @@ func Broadcast(t string, msg interface{}) {
return return
} }
w := data.WebsocketNotification{} w := WebsocketNotification{}
w.Type = t w.Type = t
w.Data = msg w.Data = msg
b, err := json.Marshal(w) b, err := json.Marshal(w)

View File

@ -19,7 +19,6 @@ import (
"github.com/GuiaBolso/darwin" "github.com/GuiaBolso/darwin"
"github.com/axllent/mailpit/config" "github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/data"
"github.com/axllent/mailpit/logger" "github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/server/websockets" "github.com/axllent/mailpit/server/websockets"
"github.com/jhillyerd/enmime" "github.com/jhillyerd/enmime"
@ -231,7 +230,7 @@ func Store(body []byte) (string, error) {
} }
// return summary // return summary
c := &data.Summary{} c := &Summary{}
if err := json.Unmarshal(b, c); err != nil { if err := json.Unmarshal(b, c); err != nil {
return "", err return "", err
} }
@ -247,8 +246,8 @@ func Store(body []byte) (string, error) {
// List returns a subset of messages from the mailbox, // List returns a subset of messages from the mailbox,
// sorted latest to oldest // sorted latest to oldest
func List(start, limit int) ([]data.Summary, error) { func List(start, limit int) ([]Summary, error) {
results := []data.Summary{} results := []Summary{}
q := sqlf.From("mailbox"). q := sqlf.From("mailbox").
Select(`ID, Data, Read`). Select(`ID, Data, Read`).
@ -260,7 +259,7 @@ func List(start, limit int) ([]data.Summary, error) {
var id string var id string
var summary string var summary string
var read int var read int
em := data.Summary{} em := Summary{}
if err := row.Scan(&id, &summary, &read); err != nil { if err := row.Scan(&id, &summary, &read); err != nil {
logger.Log().Error(err) logger.Log().Error(err)
@ -291,8 +290,8 @@ func List(start, limit int) ([]data.Summary, error) {
// The search is broken up by segments (exact phrases can be quoted), and interprits specific terms such as: // The search is broken up by segments (exact phrases can be quoted), and interprits specific terms such as:
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term> // is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
// Negative searches also also included by prefixing the search term with a `-` or `!` // Negative searches also also included by prefixing the search term with a `-` or `!`
func Search(search string) ([]data.Summary, error) { func Search(search string) ([]Summary, error) {
results := []data.Summary{} results := []Summary{}
start := time.Now() start := time.Now()
s := strings.ToLower(search) s := strings.ToLower(search)
@ -305,8 +304,7 @@ func Search(search string) ([]data.Summary, error) {
p := shellwords.NewParser() p := shellwords.NewParser()
args, err := p.Parse(s) args, err := p.Parse(s)
if err != nil { if err != nil {
// return errors.New("Your search contains invalid characters") return results, errors.New("Your search contains invalid characters")
panic(err)
} }
// generate the SQL based on arguments // generate the SQL based on arguments
@ -317,7 +315,7 @@ func Search(search string) ([]data.Summary, error) {
var summary string var summary string
var read int var read int
var ignore string var ignore string
em := data.Summary{} em := Summary{}
if err := row.Scan(&id, &summary, &read, &ignore, &ignore, &ignore, &ignore); err != nil { if err := row.Scan(&id, &summary, &read, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Error(err) logger.Log().Error(err)
@ -348,7 +346,7 @@ func Search(search string) ([]data.Summary, error) {
} }
// GetMessage returns a data.Message generated from the mailbox_data collection. // GetMessage returns a data.Message generated from the mailbox_data collection.
func GetMessage(id string) (*data.Message, error) { func GetMessage(id string) (*Message, error) {
raw, err := GetMessageRaw(id) raw, err := GetMessageRaw(id)
if err != nil { if err != nil {
return nil, err return nil, err
@ -371,7 +369,7 @@ func GetMessage(id string) (*data.Message, error) {
date, _ := env.Date() date, _ := env.Date()
obj := data.Message{ obj := Message{
ID: id, ID: id,
Read: true, Read: true,
From: from, From: from,
@ -384,28 +382,26 @@ func GetMessage(id string) (*data.Message, error) {
Text: env.Text, Text: env.Text,
} }
html := env.HTML
// strip base tags // strip base tags
var re = regexp.MustCompile(`(?U)<base .*>`) var re = regexp.MustCompile(`(?U)<base .*>`)
html = re.ReplaceAllString(html, "") html := re.ReplaceAllString(env.HTML, "")
obj.HTML = html obj.HTML = html
for _, i := range env.Inlines { for _, i := range env.Inlines {
if i.FileName != "" || i.ContentID != "" { if i.FileName != "" || i.ContentID != "" {
obj.Inline = append(obj.Inline, data.AttachmentSummary(i)) obj.Inline = append(obj.Inline, AttachmentSummary(i))
} }
} }
for _, i := range env.OtherParts { for _, i := range env.OtherParts {
if i.FileName != "" || i.ContentID != "" { if i.FileName != "" || i.ContentID != "" {
obj.Inline = append(obj.Inline, data.AttachmentSummary(i)) obj.Inline = append(obj.Inline, AttachmentSummary(i))
} }
} }
for _, a := range env.Attachments { for _, a := range env.Attachments {
if a.FileName != "" || a.ContentID != "" { if a.FileName != "" || a.ContentID != "" {
obj.Attachments = append(obj.Attachments, data.AttachmentSummary(a)) obj.Attachments = append(obj.Attachments, AttachmentSummary(a))
} }
} }
@ -650,7 +646,7 @@ func DeleteAllMessages() error {
} }
// StatsGet returns the total/unread statistics for a mailbox // StatsGet returns the total/unread statistics for a mailbox
func StatsGet() data.MailboxStats { func StatsGet() MailboxStats {
var ( var (
start = time.Now() start = time.Now()
total = CountTotal() total = CountTotal()
@ -661,7 +657,7 @@ func StatsGet() data.MailboxStats {
dbLastAction = time.Now() dbLastAction = time.Now()
return data.MailboxStats{ return MailboxStats{
Total: total, Total: total,
Unread: unread, Unread: unread,
} }

View File

@ -1,5 +1,4 @@
// Package data contains the message & mailbox structs package storage
package data
import ( import (
"net/mail" "net/mail"
@ -48,6 +47,12 @@ type Summary struct {
Attachments int Attachments int
} }
// MailboxStats struct for quick mailbox total/read lookups
type MailboxStats struct {
Total int
Unread int
}
// AttachmentSummary returns a summary of the attachment without any binary data // AttachmentSummary returns a summary of the attachment without any binary data
func AttachmentSummary(a *enmime.Part) Attachment { func AttachmentSummary(a *enmime.Part) Attachment {
o := Attachment{} o := Attachment{}