mirror of
https://github.com/axllent/mailpit.git
synced 2026-03-05 06:57:01 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fced6719b1 |
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- run: go test ./storage ./server -v
|
||||
- run: go test ./storage -v
|
||||
- run: go test ./storage -bench=.
|
||||
|
||||
# build the assets
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -2,25 +2,6 @@
|
||||
|
||||
Notable changes to Mailpit will be documented in this file.
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Feature
|
||||
- Add REST API
|
||||
|
||||
### Testing
|
||||
- Add API tests
|
||||
|
||||
### UI
|
||||
- Changes to use new data API
|
||||
- Hide delete all / mark all read in message view
|
||||
|
||||
|
||||
## 1.1.7
|
||||
|
||||
### Fix
|
||||
- Normalize running binary name detection (Windows)
|
||||
|
||||
|
||||
## 1.1.6
|
||||
|
||||
### Fix
|
||||
|
||||
@@ -30,7 +30,6 @@ 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)
|
||||
|
||||
|
||||
|
||||
@@ -55,9 +55,6 @@ 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
|
||||
|
||||
@@ -20,6 +20,7 @@ type Message struct {
|
||||
Date time.Time
|
||||
Text string
|
||||
HTML string
|
||||
HTMLSource string
|
||||
Size int
|
||||
Inline []Attachment
|
||||
Attachments []Attachment
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
# Message
|
||||
|
||||
Returns a summary of the message and attachments.
|
||||
|
||||
**URL** : `api/v1/message/<ID>`
|
||||
|
||||
**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/<ID>/part/<PartID>`
|
||||
|
||||
**Method** : `GET`
|
||||
|
||||
Returns the attachment using the MIME type provided by the attachment `ContentType`.
|
||||
|
||||
---
|
||||
## Raw (source) email
|
||||
|
||||
**URL** : `api/v1/message/<ID>/raw`
|
||||
|
||||
**Method** : `GET`
|
||||
|
||||
Returns the original email source including headers and attachments.
|
||||
@@ -1,166 +0,0 @@
|
||||
# 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": ["<ID>","<ID>"...]
|
||||
}
|
||||
```
|
||||
|
||||
### 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": ["<ID>","<ID>"...],
|
||||
"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`
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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
|
||||
@@ -1,67 +0,0 @@
|
||||
# Search
|
||||
|
||||
**URL** : `api/v1/search?query=<string>`
|
||||
|
||||
**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
|
||||
12
main.go
12
main.go
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/cmd"
|
||||
sendmail "github.com/axllent/mailpit/sendmail/cmd"
|
||||
@@ -16,19 +15,10 @@ func main() {
|
||||
}
|
||||
|
||||
// running directly
|
||||
if normalize(filepath.Base(exec)) == normalize(filepath.Base(os.Args[0])) {
|
||||
if filepath.Base(exec) == filepath.Base(os.Args[0]) {
|
||||
cmd.Execute()
|
||||
} else {
|
||||
// symlinked
|
||||
sendmail.Run()
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize returns a lowercase string stripped of the file extension (if exists).
|
||||
// Used for detecting Windows commands which ignores letter casing and `.exe`.
|
||||
// eg: "MaIlpIT.Exe" returns "mailpit"
|
||||
func normalize(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
|
||||
return strings.TrimSuffix(s, filepath.Ext(s))
|
||||
}
|
||||
|
||||
279
server/api.go
Normal file
279
server/api.go
Normal file
@@ -0,0 +1,279 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -3,16 +3,17 @@ 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"
|
||||
)
|
||||
@@ -20,6 +21,8 @@ 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")
|
||||
@@ -32,12 +35,22 @@ func Listen() {
|
||||
|
||||
go websockets.MessageHub.Run()
|
||||
|
||||
r := defaultRoutes()
|
||||
|
||||
// web UI websocket
|
||||
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.HandleFunc("/api/events", apiWebsocket).Methods("GET")
|
||||
|
||||
// virtual filesystem for others
|
||||
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")
|
||||
r.PathPrefix("/").Handler(middlewareHandler(http.FileServer(http.FS(serverRoot))))
|
||||
http.Handle("/", r)
|
||||
|
||||
@@ -54,22 +67,6 @@ 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"`)
|
||||
@@ -91,7 +88,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", config.ContentSecurityPolicy)
|
||||
w.Header().Set("Content-Security-Policy", contentSecurityPolicy)
|
||||
|
||||
if config.UIAuthFile != "" {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
@@ -124,7 +121,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", config.ContentSecurityPolicy)
|
||||
w.Header().Set("Content-Security-Policy", contentSecurityPolicy)
|
||||
|
||||
if config.UIAuthFile != "" {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
@@ -151,7 +148,38 @@ func middlewareHandler(h http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// Websocket to broadcast changes
|
||||
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
websockets.ServeWs(websockets.MessageHub, w, r)
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/server/apiv1"
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
var (
|
||||
putDataStruct struct {
|
||||
Read bool `json:"read"`
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
)
|
||||
|
||||
func Test_APIv1(t *testing.T) {
|
||||
setup()
|
||||
defer storage.Close()
|
||||
|
||||
r := defaultRoutes()
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
m, err := fetchMessages(ts.URL + "/api/v1/messages")
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// check count of empty database
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 0)
|
||||
|
||||
// insert 100
|
||||
t.Log("Insert 100 messages")
|
||||
insertEmailData(t)
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
|
||||
|
||||
// store this for later tests
|
||||
|
||||
m, err = fetchMessages(ts.URL + "/api/v1/messages")
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// read first 10
|
||||
t.Log("Read first 10 messages")
|
||||
putIDS := []string{}
|
||||
for indx, msg := range m.Messages {
|
||||
if indx == 10 {
|
||||
break
|
||||
}
|
||||
|
||||
_, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// store for later
|
||||
putIDS = append(putIDS, msg.ID)
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100)
|
||||
|
||||
// mark first 10 as unread
|
||||
t.Log("Mark first 10 as unread")
|
||||
putData := putDataStruct
|
||||
putData.IDs = putIDS
|
||||
j, err := json.Marshal(putData)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
|
||||
|
||||
// mark first 10 as read
|
||||
t.Log("Mark first 10 as read")
|
||||
putData.Read = true
|
||||
j, err = json.Marshal(putData)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100)
|
||||
|
||||
// search
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "from-1@example.com", 1)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "to:from-1@example.com", 0)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "from:@example.com", 100)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "subject:\"Subject line\"", 100)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "subject:\"Subject line 17 end\"", 1)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "!thisdoesnotexist", 100)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "-thisdoesnotexist", 100)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "thisdoesnotexist", 0)
|
||||
|
||||
// delete first 10
|
||||
t.Log("Delete first 10")
|
||||
_, err = clientDelete(ts.URL+"/api/v1/messages", string(j))
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 90)
|
||||
|
||||
// mark all as read
|
||||
putData.Read = true
|
||||
putData.IDs = []string{}
|
||||
j, err = json.Marshal(putData)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
t.Log("Mark all read")
|
||||
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 90)
|
||||
|
||||
// delete all
|
||||
t.Log("Delete all messages")
|
||||
_, err = clientDelete(ts.URL+"/api/v1/messages", "{}")
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil, received %s", err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 0)
|
||||
}
|
||||
|
||||
func setup() {
|
||||
config.NoLogging = true
|
||||
config.MaxMessages = 0
|
||||
config.DataFile = ""
|
||||
|
||||
if err := storage.InitDB(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStatsEqual(t *testing.T, uri string, unread, total int) {
|
||||
m := apiv1.MessagesResult{}
|
||||
|
||||
data, err := clientGet(uri)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assertEqual(t, unread, m.Unread, "wrong unread count")
|
||||
assertEqual(t, total, m.Total, "wrong total count")
|
||||
}
|
||||
|
||||
func assertSearchEqual(t *testing.T, uri, query string, count int) {
|
||||
t.Logf("Test search: %s", query)
|
||||
m := apiv1.MessagesResult{}
|
||||
|
||||
data, err := clientGet(uri + "?query=" + url.QueryEscape(query))
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assertEqual(t, count, m.Count, "wrong search results count")
|
||||
}
|
||||
|
||||
func insertEmailData(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
msg := enmime.Builder().
|
||||
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
|
||||
Subject(fmt.Sprintf("Subject line %d end", i)).
|
||||
Text([]byte(fmt.Sprintf("This is the email body %d <jdsauk;dwqmdqw;>.", i))).
|
||||
To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i))
|
||||
|
||||
env, err := msg.Build()
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
if err := env.Encode(buf); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
if _, err := storage.Store(buf.Bytes()); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func fetchMessages(url string) (apiv1.MessagesResult, error) {
|
||||
m := apiv1.MessagesResult{}
|
||||
|
||||
data, err := clientGet(url)
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return m, err
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func clientGet(url string) ([]byte, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := ioutil.ReadAll(resp.Body)
|
||||
|
||||
return data, err
|
||||
}
|
||||
|
||||
func clientDelete(url, body string) ([]byte, error) {
|
||||
client := new(http.Client)
|
||||
|
||||
b := strings.NewReader(body)
|
||||
req, err := http.NewRequest("DELETE", url, b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := ioutil.ReadAll(resp.Body)
|
||||
|
||||
return data, err
|
||||
}
|
||||
|
||||
func clientPut(url, body string) ([]byte, error) {
|
||||
client := new(http.Client)
|
||||
|
||||
b := strings.NewReader(body)
|
||||
req, err := http.NewRequest("PUT", url, b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := ioutil.ReadAll(resp.Body)
|
||||
|
||||
return data, err
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
if a == b {
|
||||
return
|
||||
}
|
||||
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
|
||||
t.Fatal(message)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package apiv1
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -22,8 +22,8 @@ var (
|
||||
thumbHeight = 120
|
||||
)
|
||||
|
||||
// Thumbnail returns a thumbnail image for an attachment (images only)
|
||||
func Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
// Attachment thumbnail (images only)
|
||||
func apiAttachmentThumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
@@ -75,8 +75,8 @@ export default {
|
||||
fallback: false
|
||||
});
|
||||
|
||||
this.loadMessages();
|
||||
this.connect();
|
||||
this.loadMessages();
|
||||
},
|
||||
methods: {
|
||||
loadMessages: function () {
|
||||
@@ -84,11 +84,11 @@ export default {
|
||||
let params = {};
|
||||
this.selected = [];
|
||||
|
||||
let uri = 'api/v1/messages';
|
||||
let uri = 'api/messages';
|
||||
if (self.search) {
|
||||
self.searching = true;
|
||||
self.items = [];
|
||||
uri = 'api/v1/search'
|
||||
uri = 'api/search'
|
||||
self.start = 0; // search is displayed on one page
|
||||
params['query'] = self.search;
|
||||
} else {
|
||||
@@ -104,12 +104,7 @@ export default {
|
||||
self.unread = response.data.unread;
|
||||
self.count = response.data.count;
|
||||
self.start = response.data.start;
|
||||
self.items = response.data.messages;
|
||||
|
||||
if (self.items == 0 && self.start > 0) {
|
||||
self.start = 0;
|
||||
return self.loadMessages();
|
||||
}
|
||||
self.items = response.data.items;
|
||||
|
||||
if (!self.scrollInPlace) {
|
||||
let mp = document.getElementById('message-page');
|
||||
@@ -158,7 +153,7 @@ export default {
|
||||
let self = this;
|
||||
self.selected = [];
|
||||
|
||||
let uri = 'api/v1/message/' + self.currentPath
|
||||
let uri = 'api/' + self.currentPath
|
||||
self.get(uri, false, function(response) {
|
||||
for (let i in self.items) {
|
||||
if (self.items[i].ID == self.currentPath) {
|
||||
@@ -176,14 +171,14 @@ export default {
|
||||
if (a.ContentID != '') {
|
||||
d.HTML = d.HTML.replace(
|
||||
new RegExp('cid:'+a.ContentID, 'g'),
|
||||
window.location.origin+'/api/v1/message/'+d.ID+'/part/'+a.PartID
|
||||
window.location.origin+'/api/'+d.ID+'/part/'+a.PartID
|
||||
);
|
||||
}
|
||||
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
|
||||
// some old email clients use the filename
|
||||
d.HTML = d.HTML.replace(
|
||||
new RegExp('src=(\'|")'+a.FileName+'(\'|")', 'g'),
|
||||
'src="'+window.location.origin+'/api/v1/message/'+d.ID+'/part/'+a.PartID+'"'
|
||||
'src="'+window.location.origin+'/api/'+d.ID+'/part/'+a.PartID+'"'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -195,14 +190,14 @@ export default {
|
||||
if (a.ContentID != '') {
|
||||
d.HTML = d.HTML.replace(
|
||||
new RegExp('cid:'+a.ContentID, 'g'),
|
||||
window.location.origin+'/api/v1/message/'+d.ID+'/part/'+a.PartID
|
||||
window.location.origin+'/api/'+d.ID+'/part/'+a.PartID
|
||||
);
|
||||
}
|
||||
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
|
||||
// some old email clients use the filename
|
||||
d.HTML = d.HTML.replace(
|
||||
new RegExp('src=(\'|")'+a.FileName+'(\'|")', 'g'),
|
||||
'src="'+window.location.origin+'/api/v1/message/'+d.ID+'/part/'+a.PartID+'"'
|
||||
'src="'+window.location.origin+'/api/'+d.ID+'/part/'+a.PartID+'"'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -226,32 +221,38 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
// universal handler to delete current or selected messages
|
||||
deleteMessages: function() {
|
||||
let ids = [];
|
||||
deleteAll: function() {
|
||||
let self = this;
|
||||
if (self.message) {
|
||||
ids.push(self.message.ID);
|
||||
} else {
|
||||
ids = JSON.parse(JSON.stringify(self.selected));
|
||||
}
|
||||
if (!ids.length) {
|
||||
let uri = 'api/delete'
|
||||
self.get(uri, false, function(response) {
|
||||
window.location.hash = "";
|
||||
self.reloadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
deleteOne: function() {
|
||||
let self = this;
|
||||
if (!self.message) {
|
||||
return false;
|
||||
}
|
||||
let uri = 'api/v1/messages';
|
||||
self.delete(uri, {'ids': ids}, function(response) {
|
||||
let uri = 'api/' + self.message.ID + '/delete'
|
||||
self.get(uri, false, function(response) {
|
||||
window.location.hash = "";
|
||||
self.scrollInPlace = true;
|
||||
self.loadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
deleteAll: function() {
|
||||
deleteSelected: function() {
|
||||
let self = this;
|
||||
let uri = 'api/v1/messages';
|
||||
self.delete(uri, false, function(response) {
|
||||
if (!self.selected.length) {
|
||||
return false;
|
||||
}
|
||||
let uri = 'api/delete'
|
||||
self.post(uri, {'ids': self.selected}, function(response) {
|
||||
window.location.hash = "";
|
||||
self.reloadMessages();
|
||||
self.scrollInPlace = true;
|
||||
self.loadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
@@ -260,8 +261,8 @@ export default {
|
||||
if (!self.message) {
|
||||
return false;
|
||||
}
|
||||
let uri = 'api/v1/messages';
|
||||
self.put(uri, {'read': false, 'ids': [self.message.ID]}, function(response) {
|
||||
let uri = 'api/' + self.message.ID + '/unread'
|
||||
self.get(uri, false, function(response) {
|
||||
window.location.hash = "";
|
||||
self.scrollInPlace = true;
|
||||
self.loadMessages();
|
||||
@@ -270,8 +271,8 @@ export default {
|
||||
|
||||
markAllRead: function() {
|
||||
let self = this;
|
||||
let uri = 'api/v1/messages'
|
||||
self.put(uri, {'read': true}, function(response) {
|
||||
let uri = 'api/read'
|
||||
self.get(uri, false, function(response) {
|
||||
window.location.hash = "";
|
||||
self.scrollInPlace = true;
|
||||
self.loadMessages();
|
||||
@@ -283,8 +284,8 @@ export default {
|
||||
if (!self.selected.length) {
|
||||
return false;
|
||||
}
|
||||
let uri = 'api/v1/messages';
|
||||
self.put(uri, {'read': true, 'ids': self.selected}, function(response) {
|
||||
let uri = 'api/read'
|
||||
self.post(uri, {'ids': self.selected}, function(response) {
|
||||
window.location.hash = "";
|
||||
self.scrollInPlace = true;
|
||||
self.loadMessages();
|
||||
@@ -296,40 +297,14 @@ export default {
|
||||
if (!self.selected.length) {
|
||||
return false;
|
||||
}
|
||||
let uri = 'api/v1/messages';
|
||||
self.put(uri, {'read': false, 'ids': self.selected}, function(response) {
|
||||
let uri = 'api/unread'
|
||||
self.post(uri, {'ids': self.selected}, function(response) {
|
||||
window.location.hash = "";
|
||||
self.scrollInPlace = true;
|
||||
self.loadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
// test of any selected emails are unread
|
||||
selectedHasUnread: function() {
|
||||
if (!this.selected.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i in this.items) {
|
||||
if (this.isSelected(this.items[i].ID) && !this.items[i].Read) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// test of any selected emails are read
|
||||
selectedHasRead: function() {
|
||||
if (!this.selected.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i in this.items) {
|
||||
if (this.isSelected(this.items[i].ID) && this.items[i].Read) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// websocket connect
|
||||
connect: function () {
|
||||
let wsproto = location.protocol == 'https:' ? 'wss' : 'ws';
|
||||
@@ -356,8 +331,7 @@ export default {
|
||||
}
|
||||
self.total++;
|
||||
self.unread++;
|
||||
let from = response.Data.From != null ? response.Data.From.Address : '[unknown]';
|
||||
self.browserNotify("New mail from: " + from, response.Data.Subject);
|
||||
self.browserNotify("New mail from: " + response.Data.From.Address, response.Data.Subject);
|
||||
} else if (response.Type == "prune") {
|
||||
// messages have been deleted, reload messages to adjust
|
||||
self.scrollInPlace = true;
|
||||
@@ -497,19 +471,19 @@ export default {
|
||||
<a class="btn btn-outline-secondary me-4 px-3" href="#" v-on:click="message=false" title="Return to messages">
|
||||
<i class="bi bi-arrow-return-left"></i>
|
||||
</a>
|
||||
<button class="btn btn-outline-secondary me-2" title="Delete message" v-on:click="deleteOne">
|
||||
<i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary me-2" title="Mark unread" v-on:click="markUnread">
|
||||
<i class="bi bi-eye-slash"></i> <span class="d-none d-md-inline">Mark unread</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary me-2" title="Delete message" v-on:click="deleteMessages">
|
||||
<i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span>
|
||||
</button>
|
||||
<a class="btn btn-outline-secondary float-end" :class="messageNext ? '':'disabled'" :href="'#'+messageNext" title="View next message">
|
||||
<i class="bi bi-caret-right-fill"></i>
|
||||
</a>
|
||||
<a class="btn btn-outline-secondary ms-2 me-1 float-end" :class="messagePrev ? '': 'disabled'" :href="'#'+messagePrev" title="View previous message">
|
||||
<i class="bi bi-caret-left-fill"></i>
|
||||
</a>
|
||||
<a :href="'api/v1/' + message.ID + '/raw?dl=1'" class="btn btn-outline-secondary me-2 float-end" title="Download message">
|
||||
<a :href="'api/' + message.ID + '/raw?dl=1'" class="btn btn-outline-secondary me-2 float-end" title="Download message">
|
||||
<i class="bi bi-file-arrow-down-fill"></i> <span class="d-none d-md-inline">Download</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -582,13 +556,13 @@ export default {
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="my-3" v-if="!message && unread && !selected.length">
|
||||
<li class="my-3" v-if="unread && !selected.length">
|
||||
<a href="#" data-bs-toggle="modal" data-bs-target="#MarkAllReadModal">
|
||||
<i class="bi bi-eye-fill"></i>
|
||||
Mark all read
|
||||
</a>
|
||||
</li>
|
||||
<li class="my-3" v-if="!message && total && !selected.length">
|
||||
<li class="my-3" v-if="total && !selected.length">
|
||||
<a href="#" data-bs-toggle="modal" data-bs-target="#DeleteAllModal">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
@@ -599,20 +573,20 @@ export default {
|
||||
<b class="me-2">Selected {{selected.length}}</b>
|
||||
<button class="btn btn-sm text-muted" v-on:click="selected=[]" title="Unselect messages"><i class="bi bi-x-circle"></i></button>
|
||||
</li>
|
||||
<li class="my-3 ms-2" v-if="selected.length > 0 && selectedHasUnread()">
|
||||
<li class="my-3 ms-2" v-if="unread && selected.length > 0">
|
||||
<a href="#" v-on:click="markSelectedRead">
|
||||
<i class="bi bi-eye-fill"></i>
|
||||
Mark read
|
||||
</a>
|
||||
</li>
|
||||
<li class="my-3 ms-2" v-if="selected.length > 0 && selectedHasRead()">
|
||||
<li class="my-3 ms-2" v-if="selected.length > 0">
|
||||
<a href="#" v-on:click="markSelectedUnread">
|
||||
<i class="bi bi-eye-slash"></i>
|
||||
Mark unread
|
||||
</a>
|
||||
</li>
|
||||
<li class="my-3 ms-2" v-if="total && selected.length > 0">
|
||||
<a href="#" v-on:click="deleteMessages">
|
||||
<a href="#" v-on:click="deleteSelected">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete
|
||||
</a>
|
||||
|
||||
@@ -88,16 +88,16 @@ const commonMixins = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Axios POST request
|
||||
* Axios Post request
|
||||
*
|
||||
* @params string url
|
||||
* @params array object/array values
|
||||
* @params array array parameters Object/array
|
||||
* @params function callback function
|
||||
*/
|
||||
post: function (url, data, callback) {
|
||||
post: function (url, values, callback) {
|
||||
let self = this;
|
||||
self.loading++;
|
||||
axios.post(url, data)
|
||||
axios.post(url, values)
|
||||
.then(callback)
|
||||
.catch(self.handleError)
|
||||
.then(function () {
|
||||
@@ -112,34 +112,13 @@ const commonMixins = {
|
||||
* Axios DELETE request (REST only)
|
||||
*
|
||||
* @params string url
|
||||
* @params array object/array values
|
||||
* @params array array parameters Object/array
|
||||
* @params function callback function
|
||||
*/
|
||||
delete: function (url, data, callback) {
|
||||
delete: function (url, values, callback) {
|
||||
let self = this;
|
||||
self.loading++;
|
||||
axios.delete(url, { data: data })
|
||||
.then(callback)
|
||||
.catch(self.handleError)
|
||||
.then(function () {
|
||||
// always executed
|
||||
if (self.loading > 0) {
|
||||
self.loading--;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Axios PUT request (REST only)
|
||||
*
|
||||
* @params string url
|
||||
* @params array object/array values
|
||||
* @params function callback function
|
||||
*/
|
||||
put: function (url, data, callback) {
|
||||
let self = this;
|
||||
self.loading++;
|
||||
axios.put(url, data)
|
||||
axios.delete(url, { data: values })
|
||||
.then(callback)
|
||||
.catch(self.handleError)
|
||||
.then(function () {
|
||||
|
||||
@@ -14,8 +14,8 @@ export default {
|
||||
|
||||
<template>
|
||||
<div class="mt-4 border-top pt-4">
|
||||
<a v-for="part in attachments" :href="'api/v1/message/'+message.ID+'/part/'+part.PartID" class="card attachment float-start me-3 mb-3" target="_blank" style="width: 180px">
|
||||
<img v-if="isImage(part)" :src="'api/v1/message/'+message.ID+'/part/'+part.PartID+'/thumb'" class="card-img-top" alt="">
|
||||
<a v-for="part in attachments" :href="'api/'+message.ID+'/part/'+part.PartID" class="card attachment float-start me-3 mb-3" target="_blank" style="width: 180px">
|
||||
<img v-if="isImage(part)" :src="'api/'+message.ID+'/part/'+part.PartID+'/thumb'" class="card-img-top" alt="">
|
||||
<img v-else src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg==" class="card-img-top" alt="">
|
||||
<div class="icon" v-if="!isImage(part)">
|
||||
<i class="bi" :class="attachmentIcon(part)"></i>
|
||||
|
||||
@@ -44,7 +44,7 @@ export default {
|
||||
self.renderUI();
|
||||
var tabEl = document.getElementById('nav-raw-tab');
|
||||
tabEl.addEventListener('shown.bs.tab', function (event) {
|
||||
self.srcURI = 'api/v1/message/' + self.message.ID + '/raw';
|
||||
self.srcURI = 'api/' + self.message.ID + '/raw';
|
||||
});
|
||||
},
|
||||
|
||||
@@ -174,7 +174,7 @@ export default {
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li v-for="part in allAttachments(message)">
|
||||
<a :href="'api/v1/message/'+message.ID+'/part/'+part.PartID" type="button"
|
||||
<a :href="'api/'+message.ID+'/part/'+part.PartID" type="button"
|
||||
class="dropdown-item" target="_blank">
|
||||
<i class="bi" :class="attachmentIcon(part)"></i>
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
|
||||
@@ -193,7 +193,7 @@ export default {
|
||||
aria-selected="true" v-if="message.HTML">HTML</button>
|
||||
<button class="nav-link" id="nav-html-source-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-html-source" type="button" role="tab" aria-controls="nav-html-source"
|
||||
aria-selected="false" v-if="message.HTML">HTML Source</button>
|
||||
aria-selected="false" v-if="message.HTMLSource">HTML Source</button>
|
||||
<button class="nav-link" id="nav-plain-text-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-plain-text" type="button" role="tab" aria-controls="nav-plain-text"
|
||||
aria-selected="false" :class="message.HTML == '' ? 'show':''">Text</button>
|
||||
@@ -211,8 +211,8 @@ export default {
|
||||
<Attachments v-if="allAttachments(message).length" :message="message" :attachments="allAttachments(message)"></Attachments>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-html-source" role="tabpanel"
|
||||
aria-labelledby="nav-html-source-tab" tabindex="0" v-if="message.HTML">
|
||||
<pre><code class="language-html">{{ message.HTML }}</code></pre>
|
||||
aria-labelledby="nav-html-source-tab" tabindex="0" v-if="message.HTMLSource">
|
||||
<pre><code class="language-html">{{ message.HTMLSource }}</code></pre>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel"
|
||||
aria-labelledby="nav-plain-text-tab" tabindex="0" :class="message.HTML == '' ? 'show':''">
|
||||
|
||||
@@ -1 +1,97 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="500" height="460" viewBox="0 0 132.292 121.708" xmlns:v="https://vecta.io/nano"><path d="M12.321 0l53.861 53.918L120.365 0zM5.155 9.025l60.842 59.673 61.211-59.489-.185 36.835L66.921 70.54l15.164 12.616-8.137 5.986-41.609.184c-4.838-.022-25.877-18.34-27.185-41.255z" fill-opacity=".941" fill="#2d4a5f"/><path d="M78.385 72.049l53.907-21.679-8.031 57.318-11.845-9.132c-21.727 23.171-45.255 26.289-67.997 20.837S12.281 98.39 5.155 83.8-.67 53.116 2.843 38.769c1.13 10.511-1.313 16.316 6.38 33.612 6.31 11.399 14.413 20.417 25.89 24.956 13.9 6.195 32.247 3.357 41.701-3.039l14.24-12.156z" fill="#00b786"/></svg>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="500"
|
||||
height="460"
|
||||
viewBox="0 0 132.29167 121.70833"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
|
||||
sodipodi:docname="mailpit.svg"
|
||||
inkscape:export-filename="/home/ralph/bitmap.png"
|
||||
inkscape:export-xdpi="176.09"
|
||||
inkscape:export-ydpi="176.09">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.98994949"
|
||||
inkscape:cx="90.98717"
|
||||
inkscape:cy="229.51456"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer2"
|
||||
showgrid="false"
|
||||
showguides="true"
|
||||
inkscape:guide-bbox="true"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
units="px"
|
||||
inkscape:window-width="1548"
|
||||
inkscape:window-height="838"
|
||||
inkscape:window-x="52"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-maximized="1">
|
||||
<sodipodi:guide
|
||||
position="39.014182,62.44412"
|
||||
orientation="0,1"
|
||||
id="guide4529"
|
||||
inkscape:locked="false" />
|
||||
</sodipodi:namedview>
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer2"
|
||||
inkscape:label="Layer 2"
|
||||
style="display:inline"
|
||||
transform="translate(-55.479864,-26.541592)">
|
||||
<g
|
||||
id="g4547"
|
||||
transform="matrix(1.9570423,0,0,1.9490788,-53.096581,-140.70068)"
|
||||
style="opacity:1">
|
||||
<path
|
||||
sodipodi:nodetypes="cccc"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path4534"
|
||||
d="M 61.775483,85.805801 89.296873,113.46893 116.98363,85.8058 Z"
|
||||
style="fill:#2d4a5f;fill-opacity:0.94117647;stroke:none;stroke-width:0.26499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<path
|
||||
sodipodi:nodetypes="cccccccccc"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path4540"
|
||||
d="m 58.113837,90.436008 31.088544,30.616072 31.277529,-30.521576 -0.0945,18.898806 -30.71057,12.56771 7.748511,6.47285 -4.157737,3.07105 -21.26116,0.0945 c -2.471939,-0.0114 -13.222442,-9.40933 -13.890627,-21.16666 z"
|
||||
style="fill:#2d4a5f;fill-opacity:0.94117647;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
|
||||
<path
|
||||
sodipodi:nodetypes="cccczzcccccc"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path4542"
|
||||
d="m 95.532643,122.7713 27.544977,-11.12272 -4.10354,29.40775 -6.05271,-4.68532 c -11.10189,11.88809 -23.124233,13.48775 -34.745034,10.69078 -11.620801,-2.79697 -16.420919,-10.7759 -20.062499,-18.2612 -3.64158,-7.4853 -2.976265,-15.74301 -1.181174,-23.10379 0.577547,5.393 -0.671158,8.37123 3.260045,17.24516 3.224283,5.84857 7.36483,10.47545 13.229166,12.80395 7.102803,3.17859 16.477397,1.7222 21.308409,-1.55916 l 7.276037,-6.2366 z"
|
||||
style="fill:#00b786;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 655 B After Width: | Height: | Size: 3.8 KiB |
@@ -370,16 +370,17 @@ 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,
|
||||
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,
|
||||
}
|
||||
|
||||
html := env.HTML
|
||||
@@ -387,7 +388,6 @@ func GetMessage(id string) (*data.Message, error) {
|
||||
// strip base tags
|
||||
var re = regexp.MustCompile(`(?U)<base .*>`)
|
||||
html = re.ReplaceAllString(html, "")
|
||||
obj.HTML = html
|
||||
|
||||
for _, i := range env.Inlines {
|
||||
if i.FileName != "" || i.ContentID != "" {
|
||||
@@ -407,6 +407,8 @@ func GetMessage(id string) (*data.Message, error) {
|
||||
}
|
||||
}
|
||||
|
||||
obj.HTML = html
|
||||
|
||||
// mark message as read
|
||||
if err := MarkRead(id); err != nil {
|
||||
return &obj, err
|
||||
@@ -509,7 +511,6 @@ func MarkAllRead() error {
|
||||
|
||||
_, err := sqlf.Update("mailbox").
|
||||
Set("Read", 1).
|
||||
Where("Read = ?", 0).
|
||||
ExecAndClose(context.Background(), db)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -523,29 +524,6 @@ 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) {
|
||||
@@ -677,6 +655,7 @@ 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
|
||||
|
||||
@@ -689,19 +668,6 @@ 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 {
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestMimeEmailInserts(t *testing.T) {
|
||||
|
||||
assertEqual(t, CountTotal(), 0, "incorrect number of mime emails deleted")
|
||||
|
||||
t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart))
|
||||
t.Logf("deleted %d mime emails in %s", testRuns, time.Since(delStart))
|
||||
|
||||
assertEqualStats(t, 0, 0)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user