Compare commits

...

45 Commits

Author SHA1 Message Date
Ralph Slooten
9219b2d411 Merge branch 'release/1.1.4' 2022-09-15 21:54:16 +12:00
Ralph Slooten
86abc7ea68 Release 1.1.4 2022-09-15 21:54:16 +12:00
Ralph Slooten
867dbf41d5 UI: Minor UI color change & unread count position adjustment 2022-09-15 21:52:22 +12:00
Ralph Slooten
51e458ad57 Security: Add restrictive HTTP Content-Security-Policy 2022-09-15 21:23:27 +12:00
Ralph Slooten
d29a7d6218 Update README 2022-09-15 17:40:39 +12:00
Ralph Slooten
f6a8de3215 UI: Add favicon unread message counter 2022-09-14 22:37:47 +12:00
Ralph Slooten
4e2e59ec87 Update README 2022-09-14 17:25:56 +12:00
Ralph Slooten
6aeebb9824 UI: Remove left & right borders (message list) 2022-09-14 17:14:36 +12:00
Ralph Slooten
a426f64795 Feature: Add --quiet flag to display only errors 2022-09-14 17:14:26 +12:00
Ralph Slooten
b228c9477e Merge branch 'release/1.1.3' 2022-09-14 16:46:50 +12:00
Ralph Slooten
d70f2fd196 Release 1.1.3 2022-09-14 16:46:50 +12:00
Ralph Slooten
0da89d91dd Fix: Update message download link 2022-09-14 16:45:23 +12:00
Ralph Slooten
edab9e1b6b Merge tag '1.1.2' into develop
Release 1.1.2
2022-09-14 13:44:25 +12:00
Ralph Slooten
66aead387e Merge branch 'release/1.1.2' 2022-09-14 13:44:23 +12:00
Ralph Slooten
efe1ac732e Release 1.1.2 2022-09-14 13:44:23 +12:00
Ralph Slooten
33dcd489eb UI: Allow reverse proxy subdirectories 2022-09-14 13:43:38 +12:00
Ralph Slooten
6b2e5b2e41 mod tidy 2022-09-14 13:42:13 +12:00
Ralph Slooten
812c9b99d1 Update installation instructions 2022-09-14 12:11:52 +12:00
Ralph Slooten
8202c94a43 Merge tag '1.1.1' into develop
Release 1.1.1
2022-09-12 22:12:54 +12:00
Ralph Slooten
c1d4a73440 Merge branch 'release/1.1.1' 2022-09-12 22:12:51 +12:00
Ralph Slooten
8e100ff21b Release 1.1.1 2022-09-12 22:12:51 +12:00
Ralph Slooten
088b772de5 UI: Attachment icons and image thumbnails 2022-09-12 22:11:51 +12:00
Ralph Slooten
faf8bd4a08 Merge tag '1.1.0' into develop
Release 1.1.0
2022-09-10 00:00:42 +12:00
Ralph Slooten
0e83a5a985 Merge branch 'release/1.1.0' 2022-09-10 00:00:36 +12:00
Ralph Slooten
3ee91eb6c8 Release 1.1.0 2022-09-10 00:00:36 +12:00
Ralph Slooten
5cd0a6e2f3 UI tweaks 2022-09-09 23:57:53 +12:00
Ralph Slooten
fea733a43e UI: HTML source & highlighting 2022-09-09 23:34:35 +12:00
Ralph Slooten
d4e520772e Remove redundant npm dependency ('remove') 2022-09-04 22:04:26 +12:00
Ralph Slooten
e4a7212f89 Reload UI on prev/next message 2022-09-03 23:02:10 +12:00
Ralph Slooten
e6a5fceedd UI: Add previous/next message links 2022-09-03 22:46:38 +12:00
Ralph Slooten
bf4d5fbc6b Update changelog format 2022-09-03 19:20:51 +12:00
Ralph Slooten
93c3dec66e Merge tag '1.0.0' into develop
Release 1.0.0
2022-09-03 19:13:46 +12:00
Ralph Slooten
98026e0685 Merge branch 'release/1.0.0' 2022-09-03 19:13:42 +12:00
Ralph Slooten
ecd3a97853 Release 1.0.0 2022-09-03 19:13:20 +12:00
Ralph Slooten
695270e515 Merge branch 'feature/multi-selection' into develop 2022-09-03 19:09:57 +12:00
Ralph Slooten
43403bc6f7 Feature: Multiple message selection for group actions using shift/ctrl click
Allow group actions for deleting & marking as read/unread, resolves #11
2022-09-03 19:01:54 +12:00
Ralph Slooten
6dbdbf1637 UI: Post data using 'application/json' 2022-09-03 19:01:54 +12:00
Ralph Slooten
3c81e152e6 UI: Display unknown recipients as as Undisclosed recipients 2022-09-03 19:01:53 +12:00
Ralph Slooten
9501b460c5 Update README 2022-09-03 19:01:53 +12:00
Ralph Slooten
6233cb1e07 UI: Update frontend modules & esbuild 2022-09-03 19:01:53 +12:00
Ralph Slooten
f64f377199 Feature: Search parser improvements 2022-09-03 19:01:34 +12:00
Ralph Slooten
f872424526 UI: Update frontend modules & esbuild 2022-09-01 22:01:56 +12:00
Ralph Slooten
5d530edfab feature: Search parser improvements 2022-09-01 21:45:35 +12:00
Ralph Slooten
12c54f4bb3 Update changelog format 2022-08-30 23:52:53 +12:00
Ralph Slooten
23e47c567a Merge tag '1.0.0-beta1' into develop
Release 1.0.0-beta1
2022-08-30 23:15:36 +12:00
23 changed files with 1653 additions and 800 deletions

View File

@@ -2,7 +2,6 @@
Notable changes to Mailpit will be documented in this file.
{{ if .Versions -}}
{{ if .Unreleased.CommitGroups -}}
## [Unreleased]
@@ -27,7 +26,7 @@ Notable changes to Mailpit will be documented in this file.
### {{ .Title }}
{{ range .Notes }}
{{ .Body }}
{{ end }}
{{ end -}}
{{ end }}
{{ end -}}
{{ end -}}
@@ -38,11 +37,11 @@ Notable changes to Mailpit will be documented in this file.
- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
{{ end }}
{{ end }}
{{ end -}}
{{- if .MergeCommits -}}
### Pull Requests
{{ range .MergeCommits -}}
- {{ .Header }}
{{ end }}
{{ end }}
{{ end -}}

View File

@@ -2,6 +2,73 @@
Notable changes to Mailpit will be documented in this file.
## 1.1.4
### Feature
- Add --quiet flag to display only errors
### Security
- Add restrictive HTTP Content-Security-Policy
### UI
- Minor UI color change & unread count position adjustment
- Add favicon unread message counter
- Remove left & right borders (message list)
## 1.1.3
### Fix
- Update message download link
## 1.1.2
### UI
- Allow reverse proxy subdirectories
## 1.1.1
### UI
- Attachment icons and image thumbnails
## 1.1.0
### UI
- HTML source & highlighting
- Add previous/next message links
## 1.0.0
### Feature
- Multiple message selection for group actions using shift/ctrl click
- Search parser improvements
### Feature
- Search parser improvements
### UI
- Post data using 'application/json'
- Display unknown recipients as as `Undisclosed recipients`
- Update frontend modules & esbuild
- Update frontend modules & esbuild
## 1.0.0-beta1
### BREAKING CHANGE
This release includes a major backend storage change (SQLite) that will render any previously-saved messages useless. Please delete old data to free up space. For more information see https://github.com/axllent/mailpit/issues/10
### Feature
- Switch backend storage to use SQLite
### UI
- Resize preview iframe on load
## 0.1.5
@@ -37,10 +104,10 @@ Notable changes to Mailpit will be documented in this file.
- Minor UI tweaks
- Update pagination values when new mail arrives when not on first page
### Pull Requests
- Merge pull request [#6](https://github.com/axllent/mailpit/issues/6) from KaptinLin/develop
## 0.1.2
### Feature
@@ -132,3 +199,4 @@ Notable changes to Mailpit will be documented in this file.
- Unread statistics

View File

@@ -19,12 +19,12 @@ Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster.
- Runs entirely from a single binary, no installation required
- SMTP server (default `0.0.0.0:1025`)
- Web UI to view emails (HTML format, text, source and MIME attachments, default `0.0.0.0:8025`)
- Web UI to view emails (formatted HTML, highlighted HTML source, text, raw source and MIME attachments including image thumbnails)
- Real-time web UI updates using web sockets for new mail
- Optional browser notifications for new mail (HTTPS only)
- Configurable automatic email pruning (default keeps the most recent 500 emails)
- Email storage either in a temporary or persistent database ([see wiki](https://github.com/axllent/mailpit/wiki/Email-storage))
- Fast SMTP processing & storing - approximately 300-600 emails per second depending on CPU, network speed & email size
- Fast SMTP processing & storing - approximately 70-100 emails per second depending on CPU, network speed & email size
- Can handle hundreds of thousands of emails
- 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))
@@ -34,10 +34,18 @@ Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster.
## Installation
Download a pre-built binary in the [releases](https://github.com/axllent/mailpit/releases/latest). The `mailpit` can be placed in your `$PATH`, or simply run as `./mailpit`. See `mailpit -h` for options, or see [the wiki](https://github.com/axllent/mailpit/wiki/Runtime-options) for additional information.
Linux & Mac users can install it directly to `/usr/local/bin/mailpit` with:
```bash
sudo bash < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
```
Or download a pre-built binary in the [releases](https://github.com/axllent/mailpit/releases/latest). The `mailpit` can be placed in your `$PATH`, or simply run as `./mailpit`. See `mailpit -h` for options, or see [the wiki](https://github.com/axllent/mailpit/wiki/Runtime-options) for additional information.
To build Mailpit from source see [building from source](https://github.com/axllent/mailpit/wiki/Building-from-source).
The Mailpit web UI listens by default on `http://0.0.0.0:8025`, and the SMTP port on `0.0.0.0:1025`.
### Configuring sendmail
@@ -59,6 +67,6 @@ You can build a Mailpit-specific sendmail binary from source (see [building from
I had been using MailHog for a few years to intercept and test emails generated from several projects. MailHog has a number of severe performance issues, many of the modules are horribly out of date, and other than a few accepted MRs, it is not actively developed.
Initially I started trying to upgrade a fork of MailHog (both the UI as well as the HTTP server & API), but soon discovered that it is (with all due respect) very poorly designed. It is over-engineered (split over 9 separate projects), has too many unnecessary features for my purpose, and performs exceptionally poorly when dealing with large lumbers of emails or processing any email with an attachment (a single email with a 3MB attachment can take over a minute). The API transmits a lot of duplicate and unnecessary data on every message request for all web calls, and there is no HTTP compression.
Initially I started trying to upgrade a fork of MailHog (both the UI as well as the HTTP server & API), but soon discovered that it is (with all due respect) very poorly designed. It is over-engineered (split over 9 separate projects) and has too many unnecessary features for my purpose. It performs exceptionally poorly when dealing with large amounts of emails or processing any email with an attachment (a single email with a 3MB attachment can take over a minute to ingest). The API also transmits a lot of duplicate and unnecessary data on every message request for all web calls, and there is no HTTP compression.
In order to improve it I felt it needed to be completely rewritten, and so Mailpit was born.

View File

@@ -136,6 +136,7 @@ func init() {
rootCmd.Flags().StringVar(&config.SMTPSSLCert, "smtp-ssl-cert", config.SMTPSSLCert, "SSL certificate for SMTP - requires smtp-ssl-key")
rootCmd.Flags().StringVar(&config.SMTPSSLKey, "smtp-ssl-key", config.SMTPSSLKey, "SSL key for SMTP - requires smtp-ssl-cert")
rootCmd.Flags().BoolVarP(&config.QuietLogging, "quiet", "q", false, "Quiet logging (errors only)")
rootCmd.Flags().BoolVarP(&config.VerboseLogging, "verbose", "v", false, "Verbose logging")
// deprecated 2022/08/06

View File

@@ -26,6 +26,9 @@ var (
// VerboseLogging for console output
VerboseLogging = false
// QuietLogging for console output (errors only)
QuietLogging = false
// NoLogging for tests
NoLogging = false

View File

@@ -1,3 +1,4 @@
// Package data contains the message & mailbox structs
package data
import (
@@ -19,6 +20,7 @@ type Message struct {
Date time.Time
Text string
HTML string
HTMLSource string
Size int
Inline []Attachment
Attachments []Attachment

4
go.mod
View File

@@ -5,6 +5,7 @@ go 1.18
require (
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244
github.com/axllent/semver v0.0.1
github.com/disintegration/imaging v1.6.2
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/jhillyerd/enmime v0.10.0
@@ -18,6 +19,7 @@ require (
github.com/spf13/cobra v1.5.0
github.com/spf13/pflag v1.0.5
github.com/tg123/go-htpasswd v1.2.0
golang.org/x/text v0.3.7
modernc.org/sqlite v1.18.1
)
@@ -43,10 +45,10 @@ require (
github.com/stretchr/testify v1.7.2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b // indirect
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/tools v0.1.12 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
lukechampine.com/uint128 v1.2.0 // indirect

4
go.sum
View File

@@ -33,6 +33,8 @@ github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc/go.mod h1:Y1SNZ4dRUOKX
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
@@ -136,6 +138,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d h1:3qF+Z8Hkrw9sOhrFHti9TlB1Hkac1x+DNRkv0XQiFjo=
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=

98
install.sh Normal file
View File

@@ -0,0 +1,98 @@
#!/usr/bin/env bash
GH_REPO="axllent/mailpit"
TIMEOUT=90
set -e
VERSION=$(curl --silent --location --max-time "${TIMEOUT}" "https://api.github.com/repos/${GH_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [ $? -ne 0 ]; then
echo -ne "\nThere was an error trying to check what is the latest version of ssbak.\nPlease try again later.\n"
exit 1
fi
# detect the platform
OS="$(uname)"
case $OS in
Linux)
OS='linux'
;;
FreeBSD)
OS='freebsd'
echo 'OS not supported'
exit 2
;;
NetBSD)
OS='netbsd'
echo 'OS not supported'
exit 2
;;
OpenBSD)
OS='openbsd'
echo 'OS not supported'
exit 2
;;
Darwin)
OS='darwin'
;;
SunOS)
OS='solaris'
echo 'OS not supported'
exit 2
;;
*)
echo 'OS not supported'
exit 2
;;
esac
# detect the arch
OS_type="$(uname -m)"
case "$OS_type" in
x86_64 | amd64)
OS_type='amd64'
;;
i?86 | x86)
OS_type='386'
;;
aarch64 | arm64)
OS_type='arm64'
;;
*)
echo 'OS type not supported'
exit 2
;;
esac
GH_REPO_BIN="mailpit-${OS}-${OS_type}.tar.gz"
#create tmp directory and move to it with macOS compatibility fallback
tmp_dir=$(mktemp -d 2>/dev/null || mktemp -d -t 'mailpit-install.XXXXXXXXXX')
cd "$tmp_dir"
echo "Downloading Mailpit $VERSION"
LINK="https://github.com/${GH_REPO}/releases/download/${VERSION}/${GH_REPO_BIN}"
curl --silent --location --max-time "${TIMEOUT}" "${LINK}" | tar zxf - || {
echo "Error downloading"
exit 2
}
mkdir -p /usr/local/bin || exit 2
cp mailpit /usr/local/bin/ || exit 2
chmod 755 /usr/local/bin/mailpit || exit 2
case "$OS" in
'linux')
chown root:root /usr/local/bin/mailpit || exit 2
;;
'freebsd' | 'openbsd' | 'netbsd' | 'darwin')
chown root:wheel /usr/local/bin/mailpit || exit 2
;;
*)
echo 'OS not supported'
exit 2
;;
esac
rm -rf "$tmp_dir"
echo "Installed successfully to /usr/local/bin/mailpit"

View File

@@ -19,9 +19,13 @@ func Log() *logrus.Logger {
log = logrus.New()
log.SetLevel(logrus.InfoLevel)
if config.VerboseLogging {
// verbose logging (debug)
log.SetLevel(logrus.DebugLevel)
}
if config.NoLogging {
} else if config.QuietLogging {
// show errors only
log.SetLevel(logrus.ErrorLevel)
} else if config.NoLogging {
// disable all logging (tests)
log.SetLevel(logrus.PanicLevel)
}

737
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,8 @@
"bootstrap": "^5.2.0",
"bootstrap-icons": "^1.9.1",
"moment": "^2.29.4",
"remove": "^0.1.5",
"prismjs": "^1.29.0",
"tinycon": "^0.6.8",
"vue": "^3.2.13"
},
"devDependencies": {

View File

@@ -28,7 +28,8 @@ func apiMailboxStats(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(bytes)
}
func apiListMailbox(w http.ResponseWriter, r *http.Request) {
// List messages
func apiListMessages(w http.ResponseWriter, r *http.Request) {
start, limit := getStartLimit(r)
messages, err := storage.List(start, limit)
@@ -52,17 +53,14 @@ func apiListMailbox(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(bytes)
}
func apiSearchMailbox(w http.ResponseWriter, r *http.Request) {
// Search all messages
func apiSearchMessages(w http.ResponseWriter, r *http.Request) {
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search == "" {
fourOFour(w)
return
}
// we will only return up to 200 results
start := 0
// limit := 200
messages, err := storage.Search(search)
if err != nil {
httpError(w, err.Error())
@@ -73,7 +71,7 @@ func apiSearchMailbox(w http.ResponseWriter, r *http.Request) {
var res messagesResult
res.Start = start
res.Start = 0
res.Items = messages
res.Count = len(messages)
res.Total = stats.Total
@@ -92,7 +90,7 @@ func apiOpenMessage(w http.ResponseWriter, r *http.Request) {
msg, err := storage.GetMessage(id)
if err != nil {
httpError(w, err.Error())
httpError(w, "Message not found")
return
}
@@ -123,8 +121,8 @@ func apiDownloadAttachment(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(a.Content)
}
// View the full email source as plain text
func apiDownloadSource(w http.ResponseWriter, r *http.Request) {
// Download the full email source as plain text
func apiDownloadRaw(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
@@ -144,7 +142,7 @@ func apiDownloadSource(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(data)
}
// Delete all messages in the mailbox
// Delete all messages
func apiDeleteAll(w http.ResponseWriter, r *http.Request) {
err := storage.DeleteAllMessages()
if err != nil {
@@ -156,6 +154,31 @@ func apiDeleteAll(w http.ResponseWriter, r *http.Request) {
_, _ = 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)
@@ -188,7 +211,7 @@ func apiUnreadOne(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
}
// Mark single message as unread
// Mark all messages as read
func apiMarkAllRead(w http.ResponseWriter, r *http.Request) {
err := storage.MarkAllRead()
if err != nil {
@@ -200,6 +223,56 @@ func apiMarkAllRead(w http.ResponseWriter, r *http.Request) {
_, _ = 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)

View File

@@ -21,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'; object-src 'none'; base-uri 'self';"
// Listen will start the httpd
func Listen() {
serverRoot, err := fs.Sub(embeddedFS, "ui")
@@ -34,17 +36,21 @@ func Listen() {
go websockets.MessageHub.Run()
r := mux.NewRouter()
r.HandleFunc("/api/stats", middleWareFunc(apiMailboxStats))
r.HandleFunc("/api/messages", middleWareFunc(apiListMailbox))
r.HandleFunc("/api/search", middleWareFunc(apiSearchMailbox))
r.HandleFunc("/api/delete", middleWareFunc(apiDeleteAll))
r.HandleFunc("/api/events", apiWebsocket)
r.HandleFunc("/api/read", apiMarkAllRead)
r.HandleFunc("/api/{id}/source", middleWareFunc(apiDownloadSource))
r.HandleFunc("/api/{id}/part/{partID}", middleWareFunc(apiDownloadAttachment))
r.HandleFunc("/api/{id}/delete", middleWareFunc(apiDeleteOne))
r.HandleFunc("/api/{id}/unread", middleWareFunc(apiUnreadOne))
r.HandleFunc("/api/{id}", middleWareFunc(apiOpenMessage))
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")
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)
@@ -81,6 +87,9 @@ func (w gzipResponseWriter) Write(b []byte) (int, error) {
// and gzip compression.
func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", contentSecurityPolicy)
if config.UIAuthFile != "" {
user, pass, ok := r.BasicAuth()
@@ -111,6 +120,8 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
// and gzip compression
func middlewareHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", contentSecurityPolicy)
if config.UIAuthFile != "" {
user, pass, ok := r.BasicAuth()
@@ -139,6 +150,8 @@ func middlewareHandler(h http.Handler) http.Handler {
// FourOFour returns a basic 404 message
func fourOFour(w http.ResponseWriter) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", contentSecurityPolicy)
w.WriteHeader(http.StatusNotFound)
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, "404 page not found")
@@ -146,6 +159,8 @@ func fourOFour(w http.ResponseWriter) {
// 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)

107
server/thumbnails.go Normal file
View File

@@ -0,0 +1,107 @@
package server
import (
"bufio"
"bytes"
"image"
"image/color"
"image/draw"
"image/jpeg"
"net/http"
"strings"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/storage"
"github.com/disintegration/imaging"
"github.com/gorilla/mux"
"github.com/jhillyerd/enmime"
)
var (
thumbWidth = 180
thumbHeight = 120
)
// Attachment thumbnail (images only)
func apiAttachmentThumbnail(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
}
if !strings.HasPrefix(a.ContentType, "image/") {
blankImage(a, w)
return
}
buf := bytes.NewBuffer(a.Content)
img, err := imaging.Decode(buf)
if err != nil {
// it's not an image, return default
logger.Log().Warning(err)
blankImage(a, w)
return
}
var b bytes.Buffer
foo := bufio.NewWriter(&b)
var dstImageFill *image.NRGBA
if img.Bounds().Dx() < thumbWidth || img.Bounds().Dy() < thumbHeight {
dstImageFill = imaging.Fit(img, thumbWidth, thumbHeight, imaging.Lanczos)
} else {
dstImageFill = imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos)
}
// create white image and paste image over the top
// preventing black backgrounds for transparent GIF/PNG images
dst := imaging.New(thumbWidth, thumbHeight, color.White)
// paste the original over the top
dst = imaging.OverlayCenter(dst, dstImageFill, 1.0)
if err := jpeg.Encode(foo, dst, &jpeg.Options{Quality: 70}); err != nil {
logger.Log().Warning(err)
blankImage(a, w)
return
}
w.Header().Add("Content-Type", "image/jpeg")
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
_, _ = w.Write(b.Bytes())
}
// Return a blank image instead of an error when file or image not supported
func blankImage(a *enmime.Part, w http.ResponseWriter) {
rect := image.Rect(0, 0, thumbWidth, thumbHeight)
img := image.NewRGBA(rect)
background := color.RGBA{255, 255, 255, 255}
draw.Draw(img, img.Bounds(), &image.Uniform{background}, image.ZP, draw.Src)
var b bytes.Buffer
foo := bufio.NewWriter(&b)
dstImageFill := imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos)
if err := jpeg.Encode(foo, dstImageFill, &jpeg.Options{Quality: 70}); err != nil {
logger.Log().Warning(err)
}
fileName := a.FileName
if fileName == "" {
fileName = a.ContentID
}
w.Header().Add("Content-Type", "image/jpeg")
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
_, _ = w.Write(b.Bytes())
}

View File

@@ -1,7 +1,8 @@
<script>
import commonMixins from './mixins.js'
import commonMixins from './mixins.js';
import Message from './templates/Message.vue';
import moment from 'moment'
import moment from 'moment';
import Tinycon from 'tinycon';
export default {
mixins: [commonMixins],
@@ -22,8 +23,12 @@ export default {
isConnected: false,
scrollInPlace: false,
message: false,
messagePrev: false,
messageNext: false,
notificationsSupported: false,
notificationsEnabled: false
notificationsEnabled: false,
selected: [],
tcStatus: 0
}
},
watch: {
@@ -33,6 +38,17 @@ export default {
} else {
this.message = false;
}
},
unread(v, old) {
if (v == this.tcStatus) {
return;
}
this.tcStatus = v;
if (v == 0) {
Tinycon.reset();
} else {
Tinycon.setBubble(v);
}
}
},
computed: {
@@ -53,6 +69,12 @@ export default {
&& ("Notification" in window && Notification.permission !== "denied");
this.notificationsEnabled = this.notificationsSupported && Notification.permission == "granted";
Tinycon.setOptions({
height: 11,
background: '#dd0000',
fallback: false
});
this.connect();
this.loadMessages();
},
@@ -60,6 +82,7 @@ export default {
loadMessages: function () {
let self = this;
let params = {};
this.selected = [];
let uri = 'api/messages';
if (self.search) {
@@ -128,10 +151,10 @@ export default {
openMessage: function(id) {
let self = this;
let params = {};
self.selected = [];
let uri = 'api/' + self.currentPath
self.get(uri, params, function(response) {
self.get(uri, false, function(response) {
for (let i in self.items) {
if (self.items[i].ID == self.currentPath) {
if (!self.items[i].Read) {
@@ -167,6 +190,20 @@ export default {
}
self.message = d;
// generate the prev/next links based on current message list
self.messagePrev = false;
self.messageNext = false;
let found = false;
for (let i in self.items) {
if (self.items[i].ID == self.message.ID) {
found = true;
} else if (found && !self.messageNext) {
self.messageNext = self.items[i].ID;
break;
} else {
self.messagePrev = self.items[i].ID;
}
}
});
},
@@ -174,6 +211,7 @@ export default {
let self = this;
let uri = 'api/delete'
self.get(uri, false, function(response) {
window.location.hash = "";
self.reloadMessages();
});
},
@@ -188,7 +226,19 @@ export default {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
});
},
deleteSelected: function() {
let self = this;
if (!self.selected.length) {
return false;
}
let uri = 'api/delete'
self.post(uri, {'ids': self.selected}, function(response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
});
},
@@ -215,10 +265,38 @@ export default {
});
},
markSelectedRead: function() {
let self = this;
if (!self.selected.length) {
return false;
}
let uri = 'api/read'
self.post(uri, {'ids': self.selected}, function(response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
});
},
markSelectedUnread: function() {
let self = this;
if (!self.selected.length) {
return false;
}
let uri = 'api/unread'
self.post(uri, {'ids': self.selected}, function(response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
});
},
// websocket connect
connect: function () {
let wsproto = location.protocol == 'https:' ? 'wss' : 'ws';
let ws = new WebSocket(wsproto + "://" + document.location.host + "/api/events");
let ws = new WebSocket(
wsproto + "://" + document.location.host + document.location.pathname + "api/events"
);
let self = this;
ws.onmessage = function (e) {
let response = JSON.parse(e.data);
@@ -270,7 +348,7 @@ export default {
return message.To[i].Address;
}
return '[ Unknown ]';
return '[ Undisclosed recipients ]';
},
getRelativeCreated: function(message) {
@@ -310,8 +388,54 @@ export default {
}
});
}
}
},
toggleSelected: function(e, id) {
e.preventDefault();
if (this.isSelected(id)) {
this.selected = this.selected.filter(function(ele){
return ele != id;
});
} else {
this.selected.push(id);
}
},
selectRange: function(e, id) {
e.preventDefault();
let selecting = false;
let lastSelected = this.selected.length > 0 && this.selected[this.selected.length - 1];
if (lastSelected == id) {
this.selected = this.selected.filter(function(ele){
return ele != id;
});
return
}
if (lastSelected === false) {
this.selected.push(id);
return;
}
for (let d of this.items) {
if (selecting) {
this.selected.push(d.ID);
if (d.ID == lastSelected || d.ID == id) {
// reached backwards select
break;
}
} else if (d.ID == id || d.ID == lastSelected) {
this.selected.push(d.ID);
selecting = true;
}
}
},
isSelected: function(id) {
return this.selected.indexOf(id) != -1;
}
}
}
</script>
@@ -333,9 +457,15 @@ export default {
<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-envelope"></i> <span class="d-none d-md-inline">Mark unread</span>
<i class="bi bi-eye-slash"></i> <span class="d-none d-md-inline">Mark unread</span>
</button>
<a :href="'api/' + message.ID + '/source?dl=1'" class="btn btn-outline-secondary me-2 float-end" title="Download message">
<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/' + 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>
@@ -378,11 +508,10 @@ export default {
<small>
<b>{{ formatNumber(start + 1) }}-{{ formatNumber(start + items.length) }}</b> of <b>{{ formatNumber(total) }}</b>
</small>
<button class="btn btn-outline-secondary ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev"
v-if="!searching">
<button class="btn btn-outline-secondary ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev" v-if="!searching" :title="'View previous '+limit+' messages'">
<i class="bi bi-caret-left-fill"></i>
</button>
<button class="btn btn-outline-secondary" :disabled="!canNext" v-on:click="viewNext" v-if="!searching">
<button class="btn btn-outline-secondary" :disabled="!canNext" v-on:click="viewNext" v-if="!searching" :title="'View next '+limit+' messages'">
<i class="bi bi-caret-right-fill"></i>
</button>
</span>
@@ -404,23 +533,47 @@ export default {
<i class="bi bi-envelope me-1" v-if="isConnected"></i>
<i class="bi bi-arrow-clockwise me-1" v-else></i>
Inbox
<span style="margin-top: -5px; margin-left: 5px;" class="position-absolute badge rounded-pill text-bg-secondary" title="Unread messages" v-if="unread">
<span class="badge rounded-pill text-bg-primary ms-1" title="Unread messages" v-if="unread">
{{ formatNumber(unread) }}
</span>
</a>
</li>
<li class="my-3" v-if="unread">
<li class="my-3" v-if="unread && !selected.length">
<a href="#" data-bs-toggle="modal" data-bs-target="#MarkAllReadModal">
<i class="bi bi-check2-square"></i>
<i class="bi bi-eye-fill"></i>
Mark all read
</a>
</li>
<li class="my-3" v-if="total">
<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
</a>
</li>
<li class="my-3" v-if="selected.length > 0">
<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="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">
<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="deleteSelected">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete
</a>
</li>
<li class="my-3" v-if="notificationsSupported && !notificationsEnabled">
<a href="#" data-bs-toggle="modal" data-bs-target="#EnableNotificationsModal" title="Enable browser notifications">
<i class="bi bi-bell"></i>
@@ -439,14 +592,15 @@ export default {
<div class="col-lg-10 col-md-9 mh-100 pe-0">
<div class="mh-100" style="overflow-y: auto;" :class="message ? 'd-none':''" id="message-page">
<div class="list-group" v-if="items.length">
<a v-for="message in items" :href="'#'+message.ID" class="row message d-flex small list-group-item list-group-item-action"
:class="message.Read ? 'read':''" XXXv-on:click="openMessage(message)">
<a v-for="message in items" :href="'#'+message.ID"
v-on:click.ctrl="toggleSelected($event, message.ID)" v-on:click.shift="selectRange($event, message.ID)"
class="row message d-flex small list-group-item list-group-item-action border-start-0 border-end-0"
:class="message.Read ? 'read':'', isSelected(message.ID) ? 'selected':''">
<div class="col-lg-3">
<div class="d-lg-none float-end text-muted text-nowrap small">
<i class="bi bi-paperclip h6 me-1" v-if="message.Attachments"></i>
{{ getRelativeCreated(message) }}
</div>
<div class="text-truncate d-lg-none privacy">
<span v-if="message.From" :title="message.From.Address">{{ message.From.Name ? message.From.Name : message.From.Address }}</span>
</div>
@@ -497,17 +651,17 @@ export default {
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
This will permanently delete {{ formatNumber(total) }} message<span v-if="total > 1">s</span>.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" v-on:click="deleteAll">Delete</button>
</div>
<div class="modal-header">
<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
This will permanently delete {{ formatNumber(total) }} message<span v-if="total > 1">s</span>.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" v-on:click="deleteAll">Delete</button>
</div>
</div>
</div>
</div>
@@ -516,17 +670,17 @@ export default {
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="MarkAllReadModalLabel">Mark all messages as read?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
This will mark {{ formatNumber(unread) }} message<span v-if="unread > 1">s</span> as read.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" v-on:click="markAllRead">Confirm</button>
</div>
<div class="modal-header">
<h5 class="modal-title" id="MarkAllReadModalLabel">Mark all messages as read?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
This will mark {{ formatNumber(unread) }} message<span v-if="unread > 1">s</span> as read.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" v-on:click="markAllRead">Confirm</button>
</div>
</div>
</div>
</div>
@@ -535,21 +689,21 @@ export default {
<div class="modal fade" id="EnableNotificationsModal" tabindex="-1" aria-labelledby="EnableNotificationsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="EnableNotificationsModalLabel">Enable browser notifications?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="h4">Get browser notifications when Mailpit receives a new mail?</p>
<p>
Note that your browser will ask you for confirmation when you click <code>enable notifications</code>,
and that you must have Mailpit open in a browser tab to be able to receive the notifications.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" v-on:click="requestNotifications">Enable notifications</button>
</div>
<div class="modal-header">
<h5 class="modal-title" id="EnableNotificationsModalLabel">Enable browser notifications?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="h4">Get browser notifications when Mailpit receives a new mail?</p>
<p>
Note that your browser will ask you for confirmation when you click <code>enable notifications</code>,
and that you must have Mailpit open in a browser tab to be able to receive the notifications.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" v-on:click="requestNotifications">Enable notifications</button>
</div>
</div>
</div>
</div>

View File

@@ -1 +1,2 @@
$link-decoration: none;
$primary: #3465b5;

View File

@@ -34,18 +34,18 @@
z-index: 1500;
}
.message.read:not(.active) {
.message.read:not(.active):not(.selected) {
color: $gray-500;
}
#nav-plain-text,
#nav-plain-text .text-view,
#nav-source {
white-space: pre;
font-family: Courier New, Courier, System, fixed-width;
font-size: 0.85em;
}
#nav-plain-text {
#nav-plain-text .text-view {
white-space: pre-wrap;
}
@@ -75,8 +75,212 @@
border-top: 0;
}
.message.selected {
background: $primary;
color: #fff;
.text-muted {
color: #fff !important;
}
&.read {
b {
font-weight: normal;
}
}
}
body.blur {
.privacy {
filter: blur(3px);
}
}
.card.attachment {
color: $gray-800;
.icon {
position: absolute;
top: 18px;
left: 0;
right: 0;
font-size: 3.5rem;
text-align: center;
color: $gray-300;
}
.card-body {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow: hidden;
opacity: 0;
}
.card-footer {
background: $gray-300;
.bi {
font-size: 1.3em;
margin-left: -10px;
}
}
&:hover {
.card-body {
opacity: 1;
background: $gray-300;
}
}
}
/* PrismJS 1.29.0 - modified!
https://prismjs.com/download.html#themes=prism-coy&languages=markup+css */
code[class*="language-"],
pre[class*="language-"] {
color: #000;
background: 0 0;
font-size: 0.85em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*="language-"] {
position: relative;
overflow: visible;
}
pre[class*="language-"] > code {
position: relative;
z-index: 1;
}
code[class*="language-"] {
max-height: inherit;
height: inherit;
padding: 0 1em;
display: block;
overflow: auto;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background-color: #fdfdfd;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
margin-bottom: 1em;
}
:not(pre) > code[class*="language-"] {
position: relative;
padding: 0.2em;
border-radius: 0.3em;
color: #c92c2c;
border: 1px solid rgba(0, 0, 0, 0.1);
display: inline;
white-space: normal;
}
.token.block-comment,
.token.cdata,
.token.comment,
.token.doctype,
.token.prolog {
color: #7d8b99;
}
.token.punctuation {
color: #5f6364;
}
.token.boolean,
.token.constant,
.token.deleted,
.token.function-name,
.token.number,
.token.property,
.token.symbol,
.token.tag {
color: #c92c2c;
}
.token.attr-name,
.token.builtin,
.token.char,
.token.function,
.token.inserted,
.token.selector,
.token.string {
color: #2f9c0a;
}
.token.entity,
.token.operator,
.token.url,
.token.variable {
color: #a67f59;
background: rgba(255, 255, 255, 0.5);
}
.token.atrule,
.token.attr-value,
.token.class-name,
.token.keyword {
color: #1990b8;
}
.token.important,
.token.regex {
color: #e90;
}
.language-css .token.string,
.style .token.string {
color: #a67f59;
background: rgba(255, 255, 255, 0.5);
}
.token.important {
font-weight: 400;
}
.token.bold {
font-weight: 700;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
.token.namespace {
opacity: 0.7;
}
@media screen and (max-width: 767px) {
pre[class*="language-"]:after,
pre[class*="language-"]:before {
bottom: 14px;
box-shadow: none;
}
}
pre[class*="language-"].line-numbers.line-numbers {
padding-left: 0;
}
pre[class*="language-"].line-numbers.line-numbers code {
padding-left: 3.8em;
}
pre[class*="language-"].line-numbers.line-numbers .line-numbers-rows {
left: 0;
}
pre[class*="language-"][data-line] {
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
}
pre[data-line] code {
position: relative;
padding-left: 4em;
}
pre .line-highlight {
margin-top: 0;
}

View File

@@ -8,131 +8,183 @@ FakeModal.prototype.show = function () { alert('open fake modal') }
/* Common mixin functions used in apps */
const commonMixins = {
data() {
return {
loading: 0,
}
},
data() {
return {
loading: 0,
}
},
methods: {
getFileSize: function (bytes) {
var i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i];
},
methods: {
getFileSize: function (bytes) {
var i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i];
},
formatNumber: function (nr) {
return new Intl.NumberFormat().format(nr);
},
formatNumber: function (nr) {
return new Intl.NumberFormat().format(nr);
},
// Ajax error message
handleError: function (error) {
// handle error
if (error.response && error.response.data) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (error.response.data.Error) {
alert(error.response.data.Error)
} else {
alert(error.response.data);
}
} else if (error.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
alert('Error sending data to the server. Please try again.');
} else {
// Something happened in setting up the request that triggered an Error
alert(error.message);
}
},
// Ajax error message
handleError: function (error) {
// handle error
if (error.response && error.response.data) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (error.response.data.Error) {
alert(error.response.data.Error)
} else {
alert(error.response.data);
}
} else if (error.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
alert('Error sending data to the server. Please try again.');
} else {
// Something happened in setting up the request that triggered an Error
alert(error.message);
}
},
// generic modal get/set function
modal: function (id) {
let e = document.getElementById(id);
if (e) {
return bootstrap.Modal.getOrCreateInstance(e);
}
// in case there are open/close actions
return new FakeModal();
},
// generic modal get/set function
modal: function (id) {
let e = document.getElementById(id);
if (e) {
return bootstrap.Modal.getOrCreateInstance(e);
}
// in case there are open/close actions
return new FakeModal();
},
// generic modal get/set function
offcanvas: function (id) {
var e = document.getElementById(id);
if (e) {
return bootstrap.Offcanvas.getOrCreateInstance(e);
}
// in case there are open/close actions
return new FakeModal();
},
// generic modal get/set function
offcanvas: function (id) {
var e = document.getElementById(id);
if (e) {
return bootstrap.Offcanvas.getOrCreateInstance(e);
}
// in case there are open/close actions
return new FakeModal();
},
/**
* Axios GET request
*
* @params string url
* @params array array parameters Object/array
* @params function callback function
*/
get: function (url, values, callback) {
let self = this;
self.loading++;
axios.get(url, { params: values })
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
}
});
},
/**
* Axios GET request
*
* @params string url
* @params array array parameters Object/array
* @params function callback function
*/
get: function (url, values, callback) {
let self = this;
self.loading++;
axios.get(url, { params: values })
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
}
});
},
/**
* Axios Post request
*
* @params string url
* @params array array parameters Object/array
* @params function callback function
*/
post: function (url, values, callback) {
let self = this;
const params = new URLSearchParams();
for (const [key, value] of Object.entries(values)) {
params.append(key, value);
}
self.loading++;
axios.post(url, params)
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
}
});
},
/**
* Axios Post request
*
* @params string url
* @params array array parameters Object/array
* @params function callback function
*/
post: function (url, values, callback) {
let self = this;
self.loading++;
axios.post(url, values)
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
}
});
},
/**
* Axios DELETE request (REST only)
*
* @params string url
* @params array array parameters Object/array
* @params function callback function
*/
delete: function (url, values, callback) {
let self = this;
self.loading++;
axios.delete(url, { data: values })
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
}
});
}
}
/**
* Axios DELETE request (REST only)
*
* @params string url
* @params array array parameters Object/array
* @params function callback function
*/
delete: function (url, values, callback) {
let self = this;
self.loading++;
axios.delete(url, { data: values })
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
}
});
},
allAttachments: function (message) {
let a = [];
for (let i in message.Attachments) {
a.push(message.Attachments[i]);
}
for (let i in message.OtherParts) {
a.push(message.OtherParts[i]);
}
for (let i in message.Inline) {
a.push(message.Inline[i]);
}
return a.length ? a : false;
},
isImage(a) {
return a.ContentType.match(/^image\//);
},
attachmentIcon: function (a) {
let ext = a.FileName.split('.').pop().toLowerCase();
if (a.ContentType.match(/^image\//)) {
return 'bi-file-image-fill';
}
if (a.ContentType.match(/\/pdf$/) || ext == 'pdf') {
return 'bi-file-pdf-fill';
}
if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) {
return 'bi-file-word-fill';
}
if (['xls', 'xlsx', 'ods'].includes(ext)) {
return 'bi-file-spreadsheet-fill';
}
if (['ppt', 'pptx', 'key', 'ppt', 'odp'].includes(ext)) {
return 'bi-file-slides-fill';
}
if (['zip', 'tar', 'rar', 'bz2', 'gz', 'xz'].includes(ext)) {
return 'bi-file-zip-fill';
}
if (a.ContentType.match(/^audio\//)) {
return 'bi-file-music-fill';
}
if (a.ContentType.match(/^video\//)) {
return 'bi-file-play-fill';
}
if (a.ContentType.match(/\/calendar$/)) {
return 'bi-file-check-fill';
}
if (a.ContentType.match(/^text\//) || ['txt', 'sh', 'log'].includes(ext)) {
return 'bi-file-text-fill';
}
return 'bi-file-arrow-down-fill';
}
}
}

View File

@@ -0,0 +1,37 @@
<script>
import commonMixins from '../mixins.js';
export default {
props: {
message: Object,
attachments: Object
},
mixins: [commonMixins]
}
</script>
<template>
<div class="mt-4 border-top pt-4">
<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>
</div>
<div class="card-body border-0">
<p class="mb-1 text-muted">
<i class="bi me-1" :class="attachmentIcon(part)"></i>
<small>{{ getFileSize(part.Size) }}</small>
</p>
<p class="card-text mb-0 small">
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
</p>
</div>
<div class="card-footer small border-0 text-center text-truncate">
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
</div>
</a>
</div>
</template>

View File

@@ -1,13 +1,21 @@
<script>
import commonMixins from '../mixins.js';
import moment from 'moment'
import moment from 'moment';
import Prism from "prismjs";
import Attachments from './Attachments.vue';
export default {
props: {
message: Object
},
components: {
Attachments
},
mixins: [commonMixins],
data() {
return {
srcURI: false,
@@ -15,36 +23,28 @@ export default {
}
},
watch: {
message: {
handler(newQuestion) {
let self = this;
// delay 100ms to select first tab and add HTML highlighting (prev/next)
window.setTimeout(function() {
self.renderUI();
}, 100)
},
// force eager callback execution
immediate: true
}
},
mounted() {
var self = this;
let self = this;
window.addEventListener("resize", self.resizeIframes);
// click the first non-disabled tab
document.querySelector('#nav-tab button:not([disabled])').click();
document.activeElement.blur(); // blur focus
window.setTimeout(function(){
let p = document.getElementById('preview-html');
if (p) {
// make links open in new window
let anchorEls = p.contentWindow.document.body.querySelectorAll('a');
for (var i = 0; i < anchorEls.length; i++) {
let anchorEl = anchorEls[i];
let href = anchorEl.getAttribute('href');
if (href && href.match(/^http/)) {
anchorEl.setAttribute('target', '_blank');
}
}
self.resizeIframes();
}
}, 200);
var tabEl = document.getElementById('nav-source-tab');
self.renderUI();
var tabEl = document.getElementById('nav-raw-tab');
tabEl.addEventListener('shown.bs.tab', function (event) {
self.srcURI = 'api/' + self.message.ID + '/source';
self.srcURI = 'api/' + self.message.ID + '/raw';
});
},
@@ -53,6 +53,37 @@ export default {
},
methods: {
renderUI: function() {
let self = this;
// click the first non-disabled tab
document.querySelector('#nav-tab button:not([disabled])').click();
document.activeElement.blur(); // blur focus
document.getElementById('message-view').scrollTop = 0;
window.setTimeout(function(){
let p = document.getElementById('preview-html');
if (p) {
// make links open in new window
let anchorEls = p.contentWindow.document.body.querySelectorAll('a');
for (var i = 0; i < anchorEls.length; i++) {
let anchorEl = anchorEls[i];
let href = anchorEl.getAttribute('href');
if (href && href.match(/^http/)) {
anchorEl.setAttribute('target', '_blank');
}
}
self.resizeIframes();
}
}, 200);
// html highlighting
window.Prism = window.Prism || {};
window.Prism.manual = true;
Prism.highlightAll();
},
resizeIframe: function(el) {
let i = el.target;
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px';
@@ -70,20 +101,6 @@ export default {
}
},
allAttachments: function(message){
let a = [];
for (let i in message.Attachments) {
a.push(message.Attachments[i]);
}
for (let i in message.OtherParts) {
a.push(message.OtherParts[i]);
}
for (let i in message.Inline) {
a.push(message.Inline[i]);
}
return a.length ? a : false;
},
messageDate: function(d) {
return moment(d).format('ddd, D MMM YYYY, h:mm a');
}
@@ -92,73 +109,97 @@ export default {
</script>
<template>
<div v-if="message" class="mh-100" style="overflow-y: scroll;">
<table class="messageHeaders">
<tbody>
<tr class="small">
<th>From</th>
<td class="privacy">
<span v-if="message.From">
<span v-if="message.From.Name">{{ message.From.Name + " " }}</span>
<span v-if="message.From.Address">&lt;{{ message.From.Address }}&gt;</span>
</span>
<span v-else>
[ Unknown ]
</span>
</td>
</tr>
<tr class="small">
<th>To</th>
<td class="privacy">
<span v-for="(t, i) in message.To">
<template v-if="i > 0">,</template>
{{ t.Name + " <" + t.Address +">" }}
</span>
</td>
</tr>
<tr v-if="message.Cc" class="small">
<th>CC</th>
<td class="privacy">
<span v-for="(t, i) in message.Cc">
<template v-if="i > 0">,</template>
{{ t.Name + " <" + t.Address +">" }}
</span>
</td>
</tr>
<tr v-if="message.Bcc" class="small">
<th>CC</th>
<td class="privacy">
<span v-for="(t, i) in message.Bcc">
<template v-if="i > 0">,</template>
{{ t.Name + " <" + t.Address +">" }}
</span>
</td>
</tr>
<tr>
<th class="small">Subject</th>
<td><strong>{{ message.Subject }}</strong></td>
</tr>
</tbody>
</table>
<div v-if="message" id="message-view" class="mh-100" style="overflow-y: scroll;">
<div class="row w-100">
<div class="col-md">
<table class="messageHeaders">
<tbody>
<tr class="small">
<th>From</th>
<td class="privacy">
<span v-if="message.From">
<span v-if="message.From.Name">{{ message.From.Name + " " }}</span>
<span v-if="message.From.Address">&lt;{{ message.From.Address }}&gt;</span>
</span>
<span v-else>
[ Unknown ]
</span>
</td>
</tr>
<tr class="small">
<th>To</th>
<td class="privacy">
<span v-if="message.To" v-for="(t, i) in message.To">
<template v-if="i > 0">, </template>
<span class="text-nowrap">{{ t.Name + " <" + t.Address +">" }}</span>
</span>
<span v-else>Undisclosed recipients</span>
</td>
</tr>
<tr v-if="message.Cc" class="small">
<th>CC</th>
<td class="privacy">
<span v-for="(t, i) in message.Cc">
<template v-if="i > 0">,</template>
{{ t.Name + " <" + t.Address +">" }}
</span>
</td>
</tr>
<tr v-if="message.Bcc" class="small">
<th>CC</th>
<td class="privacy">
<span v-for="(t, i) in message.Bcc">
<template v-if="i > 0">,</template>
{{ t.Name + " <" + t.Address +">" }}
</span>
</td>
</tr>
<tr>
<th class="small">Subject</th>
<td><strong>{{ message.Subject }}</strong></td>
</tr>
<tr class="d-md-none">
<th class="small">Date</th>
<td>{{ messageDate(message.Date) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="col-md-auto text-md-end mt-md-3">
<p class="text-muted small d-none d-md-block"><small>{{ messageDate(message.Date) }}</small></p>
<div class="dropdown mt-2" v-if="allAttachments(message)">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Attachment<span v-if="allAttachments(message).length > 1">s</span>
({{ allAttachments(message).length }})
</button>
<ul class="dropdown-menu">
<li v-for="part in allAttachments(message)">
<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 ]' }}
<small class="text-muted ms-2">{{ getFileSize(part.Size) }}</small>
</a>
</li>
</ul>
</div>
</div>
</div>
<nav>
<div class="nav nav-tabs my-3" id="nav-tab" role="tablist">
<button class="nav-link" id="nav-html-tab" data-bs-toggle="tab"
data-bs-target="#nav-html" type="button" role="tab" aria-controls="nav-html"
aria-selected="true" :disabled="message.HTML == ''" :class="message.HTML == '' ? 'disabled':''">HTML</button>
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.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':''">Plain<span class="d-none d-md-inline"> text</span></button>
<button class="nav-link" id="nav-source-tab" data-bs-toggle="tab"
data-bs-target="#nav-source" type="button" role="tab" aria-controls="nav-source"
aria-selected="false">Source</button>
<button class="nav-link" id="nav-mime-tab" data-bs-toggle="tab" data-bs-target="#nav-mime"
type="button" role="tab" aria-controls="nav-mime" aria-selected="false"
:disabled="!allAttachments(message)" :class="!allAttachments(message) ? 'disabled':''"
>Attachments <span v-if="allAttachments(message)">({{allAttachments(message).length}})</span></button>
<div class="d-none d-lg-block ms-auto small mt-3 me-2 text-muted">
<small>{{ messageDate(message.Date) }}</small>
</div>
aria-selected="false" :class="message.HTML == '' ? 'show':''">Text</button>
<button class="nav-link" id="nav-raw-tab" data-bs-toggle="tab"
data-bs-target="#nav-raw" type="button" role="tab" aria-controls="nav-raw"
aria-selected="false">Raw</button>
</div>
</nav>
<div class="tab-content mb-5" id="nav-tabContent">
@@ -167,27 +208,22 @@ export default {
<iframe target-blank="" class="tab-pane" id="preview-html" :srcdoc="message.HTML" v-on:load="resizeIframe"
seamless frameborder="0" style="width: 100%; height: 100%;">
</iframe>
<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.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':''">
{{ message.Text }}
<div class="text-view">{{ message.Text }}</div>
<Attachments v-if="allAttachments(message).length" :message="message" :attachments="allAttachments(message)"></Attachments>
</div>
<div class="tab-pane fade" id="nav-source" role="tabpanel" aria-labelledby="nav-source-tab"
<div class="tab-pane fade" id="nav-raw" role="tabpanel" aria-labelledby="nav-raw-tab"
tabindex="0">
<iframe v-if="srcURI" :src="srcURI" v-on:load="resizeIframe"
seamless frameborder="0" style="width: 100%; height: 300px;" id="message-src"></iframe>
</div>
<div class="tab-pane fade" id="nav-mime" role="tabpanel" aria-labelledby="nav-mime-tab"
tabindex="0">
<div v-if="allAttachments(message)" v-for="part in allAttachments(message)" class="mime-part mb-2">
<a :href="'api/'+message.ID+'/part/'+part.PartID" type="button"
class="btn btn-outline-secondary btn-sm me-2" target="_blank">
<i class="bi bi-file-arrow-down-fill"></i>
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
</a>
<small class="text-muted">{{ getFileSize(part.Size) }}</small>
</div>
</div>
</div>
</div>
</template>

View File

@@ -307,81 +307,8 @@ func Search(search string) ([]data.Summary, error) {
panic(err)
}
q := sqlf.From("mailbox").
Select(`ID, Data, read,
json_extract(Data, '$.To') as ToJSON,
json_extract(Data, '$.From') as FromJSON,
json_extract(Data, '$.Subject') as Subject,
json_extract(Data, '$.Attachments') as Attachments
`).
OrderBy("Sort DESC").
Limit(200)
for _, w := range args {
if cleanString(w) == "" {
continue
}
exclude := false
if strings.HasPrefix(w, "-") || strings.HasPrefix(w, "!") {
exclude = true
w = w[1:]
}
if strings.HasPrefix(w, "to:") {
w = cleanString(w[3:])
if w != "" {
if exclude {
q.Where("ToJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("ToJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "from:") {
w = cleanString(w[5:])
if w != "" {
if exclude {
q.Where("FromJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("FromJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "subject:") {
w = cleanString(w[8:])
if w != "" {
if exclude {
q.Where("Subject NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("Subject LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if w == "is:read" {
if exclude {
q.Where("Read = 0")
} else {
q.Where("Read = 1")
}
} else if w == "is:unread" {
if exclude {
q.Where("Read = 1")
} else {
q.Where("Read = 0")
}
} else if w == "has:attachment" || w == "has:attachments" {
if exclude {
q.Where("Attachments = 0")
} else {
q.Where("Attachments > 0")
}
} else {
// search text
if exclude {
q.Where("search NOT LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
} else {
q.Where("search LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
}
}
}
// generate the SQL based on arguments
q := searchParser(args)
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var id string
@@ -443,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

95
storage/search.go Normal file
View File

@@ -0,0 +1,95 @@
package storage
import (
"regexp"
"strings"
"github.com/leporo/sqlf"
)
// SearchParser returns the SQL syntax for the database search based on the search arguments
func searchParser(args []string) *sqlf.Stmt {
q := sqlf.From("mailbox").
Select(`ID, Data, read,
json_extract(Data, '$.To') as ToJSON,
json_extract(Data, '$.From') as FromJSON,
json_extract(Data, '$.Subject') as Subject,
json_extract(Data, '$.Attachments') as Attachments
`).
OrderBy("Sort DESC").
Limit(200)
for _, w := range args {
if cleanString(w) == "" {
continue
}
exclude := false
// search terms starting with a `-` or `!` imply an exclude
if len(w) > 1 && (strings.HasPrefix(w, "-") || strings.HasPrefix(w, "!")) {
exclude = true
w = w[1:]
}
re := regexp.MustCompile(`[a-zA-Z0-9]+`)
if !re.MatchString(w) {
continue
}
if strings.HasPrefix(w, "to:") {
w = cleanString(w[3:])
if w != "" {
if exclude {
q.Where("ToJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("ToJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "from:") {
w = cleanString(w[5:])
if w != "" {
if exclude {
q.Where("FromJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("FromJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "subject:") {
w = cleanString(w[8:])
if w != "" {
if exclude {
q.Where("Subject NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("Subject LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if w == "is:read" {
if exclude {
q.Where("Read = 0")
} else {
q.Where("Read = 1")
}
} else if w == "is:unread" {
if exclude {
q.Where("Read = 1")
} else {
q.Where("Read = 0")
}
} else if w == "has:attachment" || w == "has:attachments" {
if exclude {
q.Where("Attachments = 0")
} else {
q.Where("Attachments > 0")
}
} else {
// search text
if exclude {
q.Where("search NOT LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
} else {
q.Where("search LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
}
}
}
return q
}