mirror of
https://github.com/axllent/mailpit.git
synced 2025-01-10 00:43:53 +02:00
667 lines
14 KiB
Go
667 lines
14 KiB
Go
package apiv1
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/mail"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/axllent/mailpit/config"
|
|
"github.com/axllent/mailpit/server/smtpd"
|
|
"github.com/axllent/mailpit/storage"
|
|
"github.com/axllent/mailpit/utils/logger"
|
|
"github.com/axllent/mailpit/utils/tools"
|
|
"github.com/gorilla/mux"
|
|
uuid "github.com/satori/go.uuid"
|
|
)
|
|
|
|
// 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, limit := getStartLimit(r)
|
|
|
|
messages, err := storage.List(start, limit)
|
|
if err != nil {
|
|
httpError(w, err.Error())
|
|
return
|
|
}
|
|
|
|
stats := storage.StatsGet()
|
|
|
|
var res MessagesSummary
|
|
|
|
res.Start = start
|
|
res.Messages = messages
|
|
res.Count = len(messages)
|
|
res.Total = stats.Total
|
|
res.Unread = stats.Unread
|
|
res.Tags = stats.Tags
|
|
|
|
bytes, _ := json.Marshal(res)
|
|
w.Header().Add("Content-Type", "application/json")
|
|
_, _ = w.Write(bytes)
|
|
}
|
|
|
|
// 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 the latest messages matching a search.
|
|
//
|
|
// Produces:
|
|
// - application/json
|
|
//
|
|
// Schemes: http, https
|
|
//
|
|
// Parameters:
|
|
// + name: query
|
|
// in: query
|
|
// description: Search query
|
|
// required: true
|
|
// type: string
|
|
// + name: limit
|
|
// in: query
|
|
// description: Limit results
|
|
// required: false
|
|
// type: integer
|
|
// default: 50
|
|
//
|
|
// Responses:
|
|
// 200: MessagesSummaryResponse
|
|
// default: ErrorResponse
|
|
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
|
if search == "" {
|
|
httpError(w, "Error: no search query")
|
|
return
|
|
}
|
|
|
|
start, limit := getStartLimit(r)
|
|
|
|
messages, err := storage.Search(search, start, limit)
|
|
if err != nil {
|
|
httpError(w, err.Error())
|
|
return
|
|
}
|
|
|
|
stats := storage.StatsGet()
|
|
|
|
var res MessagesSummary
|
|
|
|
res.Start = start
|
|
res.Messages = messages
|
|
res.Count = len(messages)
|
|
res.Total = stats.Total
|
|
res.Unread = stats.Unread
|
|
res.Tags = stats.Tags
|
|
|
|
bytes, _ := json.Marshal(res)
|
|
w.Header().Add("Content-Type", "application/json")
|
|
_, _ = w.Write(bytes)
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Produces:
|
|
// - application/json
|
|
//
|
|
// Schemes: http, https
|
|
//
|
|
// Parameters:
|
|
// + name: ID
|
|
// in: path
|
|
// description: Message ID
|
|
// required: true
|
|
// type: string
|
|
//
|
|
// Responses:
|
|
// 200: Message
|
|
// default: ErrorResponse
|
|
|
|
vars := mux.Vars(r)
|
|
|
|
id := vars["id"]
|
|
|
|
msg, err := storage.GetMessage(id)
|
|
if err != nil {
|
|
fourOFour(w)
|
|
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) {
|
|
// 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 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.
|
|
//
|
|
// Produces:
|
|
// - application/json
|
|
//
|
|
// Schemes: http, https
|
|
//
|
|
// Parameters:
|
|
// + name: ID
|
|
// in: path
|
|
// description: Message ID
|
|
// required: true
|
|
// type: string
|
|
//
|
|
// Responses:
|
|
// 200: MessageHeaders
|
|
// default: ErrorResponse
|
|
|
|
vars := mux.Vars(r)
|
|
|
|
id := vars["id"]
|
|
|
|
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
|
|
}
|
|
|
|
bytes, _ := json.Marshal(m.Header)
|
|
|
|
w.Header().Add("Content-Type", "application/json")
|
|
_, _ = w.Write(bytes)
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Produces:
|
|
// - text/plain
|
|
//
|
|
// Schemes: http, https
|
|
//
|
|
// Parameters:
|
|
// + name: ID
|
|
// in: path
|
|
// description: Message ID
|
|
// required: true
|
|
// type: string
|
|
//
|
|
// Responses:
|
|
// 200: TextResponse
|
|
// default: ErrorResponse
|
|
|
|
vars := mux.Vars(r)
|
|
|
|
id := vars["id"]
|
|
|
|
dl := r.FormValue("dl")
|
|
|
|
data, err := storage.GetMessageRaw(id)
|
|
if err != nil {
|
|
fourOFour(w)
|
|
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.
|
|
func DeleteMessages(w http.ResponseWriter, r *http.Request) {
|
|
// swagger:route DELETE /api/v1/messages messages Delete
|
|
//
|
|
// # Delete messages
|
|
//
|
|
// If no IDs are provided then all messages are deleted.
|
|
//
|
|
// Consumes:
|
|
// - application/json
|
|
//
|
|
// Produces:
|
|
// - text/plain
|
|
//
|
|
// Schemes: http, https
|
|
//
|
|
// Parameters:
|
|
// + name: ids
|
|
// in: body
|
|
// description: Message IDs to delete
|
|
// required: false
|
|
// type: DeleteRequest
|
|
//
|
|
// 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 {
|
|
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"))
|
|
}
|
|
|
|
// 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
|
|
//
|
|
// Parameters:
|
|
// + name: ids
|
|
// in: body
|
|
// description: Message IDs to update
|
|
// required: false
|
|
// type: SetReadStatusRequest
|
|
//
|
|
// 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"))
|
|
}
|
|
|
|
// SetTags (method: PUT) will set the tags for all provided IDs
|
|
func SetTags(w http.ResponseWriter, r *http.Request) {
|
|
// swagger:route PUT /api/v1/tags tags SetTags
|
|
//
|
|
// # Set message tags
|
|
//
|
|
// To remove all tags from a message, pass an empty tags array.
|
|
//
|
|
// Consumes:
|
|
// - application/json
|
|
//
|
|
// Produces:
|
|
// - text/plain
|
|
//
|
|
// Schemes: http, https
|
|
//
|
|
// Parameters:
|
|
// + name: ids
|
|
// in: body
|
|
// description: Message IDs to update
|
|
// required: true
|
|
// type: SetTagsRequest
|
|
//
|
|
// Responses:
|
|
// 200: OKResponse
|
|
// default: ErrorResponse
|
|
|
|
decoder := json.NewDecoder(r.Body)
|
|
|
|
var data struct {
|
|
Tags []string
|
|
IDs []string
|
|
}
|
|
|
|
err := decoder.Decode(&data)
|
|
if err != nil {
|
|
httpError(w, err.Error())
|
|
return
|
|
}
|
|
|
|
ids := data.IDs
|
|
|
|
if len(ids) > 0 {
|
|
for _, id := range ids {
|
|
if err := storage.SetTags(id, data.Tags); err != nil {
|
|
httpError(w, err.Error())
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
w.Header().Add("Content-Type", "text/plain")
|
|
_, _ = w.Write([]byte("ok"))
|
|
}
|
|
|
|
// ReleaseMessage (method: POST) will release a message via a preconfigured external SMTP server.
|
|
// If no IDs are provided then all messages are updated.
|
|
func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
|
// swagger:route POST /api/v1/message/{ID}/release message Release
|
|
//
|
|
// # Release message
|
|
//
|
|
// Release a message via a preconfigured external SMTP server..
|
|
//
|
|
// Consumes:
|
|
// - application/json
|
|
//
|
|
// Produces:
|
|
// - text/plain
|
|
//
|
|
// Schemes: http, https
|
|
//
|
|
// Parameters:
|
|
// + name: ID
|
|
// in: path
|
|
// description: Message ID
|
|
// required: true
|
|
// type: string
|
|
// + name: to
|
|
// in: body
|
|
// description: Array of email addresses to release message to
|
|
// required: true
|
|
// type: ReleaseMessageRequest
|
|
//
|
|
// Responses:
|
|
// 200: OKResponse
|
|
// default: ErrorResponse
|
|
|
|
vars := mux.Vars(r)
|
|
|
|
id := vars["id"]
|
|
|
|
msg, err := storage.GetMessageRaw(id)
|
|
if err != nil {
|
|
fourOFour(w)
|
|
return
|
|
}
|
|
|
|
decoder := json.NewDecoder(r.Body)
|
|
|
|
data := releaseMessageRequest{}
|
|
|
|
if err := decoder.Decode(&data); err != nil {
|
|
httpError(w, err.Error())
|
|
return
|
|
}
|
|
|
|
tos := data.To
|
|
if len(tos) == 0 {
|
|
httpError(w, "No valid addresses found")
|
|
return
|
|
}
|
|
|
|
for _, to := range tos {
|
|
address, err := mail.ParseAddress(to)
|
|
|
|
if err != nil {
|
|
httpError(w, "Invalid email address: "+to)
|
|
return
|
|
}
|
|
|
|
if config.SMTPRelayConfig.RecipientAllowlistRegexp != nil && !config.SMTPRelayConfig.RecipientAllowlistRegexp.MatchString(address.Address) {
|
|
httpError(w, "Mail address does not match allowlist: "+to)
|
|
return
|
|
}
|
|
}
|
|
|
|
reader := bytes.NewReader(msg)
|
|
m, err := mail.ReadMessage(reader)
|
|
if err != nil {
|
|
httpError(w, err.Error())
|
|
return
|
|
}
|
|
|
|
froms, err := m.Header.AddressList("From")
|
|
if err != nil {
|
|
httpError(w, err.Error())
|
|
return
|
|
}
|
|
|
|
from := froms[0].Address
|
|
|
|
// if sender is used, then change from to the sender
|
|
if senders, err := m.Header.AddressList("Sender"); err == nil {
|
|
from = senders[0].Address
|
|
}
|
|
|
|
msg, err = tools.RemoveMessageHeaders(msg, []string{"Bcc", "Message-Id"})
|
|
if err != nil {
|
|
httpError(w, err.Error())
|
|
return
|
|
}
|
|
|
|
// set the Return-Path and SMTP mfrom
|
|
if config.SMTPRelayConfig.ReturnPath != "" {
|
|
if m.Header.Get("Return-Path") != "<"+config.SMTPRelayConfig.ReturnPath+">" {
|
|
msg, err = tools.RemoveMessageHeaders(msg, []string{"Return-Path"})
|
|
if err != nil {
|
|
httpError(w, err.Error())
|
|
return
|
|
}
|
|
msg = append([]byte("Return-Path: <"+config.SMTPRelayConfig.ReturnPath+">\r\n"), msg...)
|
|
}
|
|
|
|
from = config.SMTPRelayConfig.ReturnPath
|
|
}
|
|
|
|
// generate unique ID
|
|
uid := uuid.NewV4().String() + "@mailpit"
|
|
// add unique ID
|
|
msg = append([]byte("Message-Id: <"+uid+">\r\n"), msg...)
|
|
|
|
if err := smtpd.Send(from, tos, msg); err != nil {
|
|
logger.Log().Errorf("[smtp] error sending message: %s", err.Error())
|
|
httpError(w, "SMTP error: "+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
|
|
}
|
|
|
|
// GetOptions returns a blank response
|
|
func GetOptions(w http.ResponseWriter, r *http.Request) {
|
|
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
_, _ = w.Write([]byte(""))
|
|
}
|