Compare commits

..

65 Commits
1.0.0 ... 1.2.1

Author SHA1 Message Date
Ralph Slooten
001e9de123 Merge branch 'release/1.2.1' 2022-10-08 23:35:23 +13:00
Ralph Slooten
b64a5b7991 Release 1.2.1 2022-10-08 23:35:23 +13:00
Ralph Slooten
906a697542 Add event.preventDefault() 2022-10-08 23:34:20 +13:00
Ralph Slooten
46dbde04ae UI: Update frontend modules 2022-10-08 23:34:20 +13:00
Ralph Slooten
a31a7c3d2c UI: Add about app modal with version update notification 2022-10-08 23:33:59 +13:00
Ralph Slooten
675704ca91 Update screenshot path 2022-10-08 23:33:58 +13:00
Ralph Slooten
d253d3164e Merge branch 'release/1.2.0' 2022-10-07 19:54:52 +13:00
Ralph Slooten
ef3da383da Release 1.2.0 2022-10-07 19:54:51 +13:00
Ralph Slooten
db6c2596a0 Merge branch 'feature/apiv1' into develop 2022-10-07 19:53:39 +13:00
Ralph Slooten
7349d838bb Fix typo 2022-10-07 19:49:19 +13:00
Ralph Slooten
d8c6364622 Testing: Add API tests 2022-10-07 19:48:50 +13:00
Ralph Slooten
df758d063a UI: Changes to use new data API 2022-10-07 19:47:41 +13:00
Ralph Slooten
34da0e5042 Feature: Add REST API
Requested feature for integration, see #15
2022-10-07 19:46:39 +13:00
Ralph Slooten
4a92b99a53 Optimise Mailpit SVG logo 2022-10-07 19:25:26 +13:00
Ralph Slooten
b1dc121cdd UI: Hide delete all / mark all read in message view 2022-10-04 17:41:25 +13:00
Ralph Slooten
e5c8ef9e8d Remove redundant files 2022-10-04 17:38:49 +13:00
Ralph Slooten
c6695c2418 Merge tag '1.1.7' into develop
Release 1.1.7
2022-09-21 16:01:10 +12:00
Ralph Slooten
53bbf4c7dc Merge branch 'release/1.1.7' 2022-09-21 16:01:08 +12:00
Ralph Slooten
0015300920 Release 1.1.7 2022-09-21 16:01:08 +12:00
Ralph Slooten
fa6a5d729f Release 1.1.7 2022-09-21 15:56:38 +12:00
Ralph Slooten
cc9fba7adf Fix: Normalize running binary name detection (Windows)
This prevents invoking sendmail when the executed name differs from the actual binary name (eg: running `mailpit` instead of `mailpit.exe`). See #14
2022-09-21 15:56:20 +12:00
Ralph Slooten
93665656cf Invoke loadMessages() before event connect()
In the case whereby the websocket is blocked (ie: error), make sure messages load is already triggered.
2022-09-21 15:56:20 +12:00
Ralph Slooten
d918fdb137 Release 1.1.6 2022-09-19 22:18:00 +12:00
Ralph Slooten
fd1346c5f4 Fix: Workaround for Safari source matching bug blocking event listener
The current stable version of Safari does not treat ws: or wss: sockets as `self`.
See: https://bugs.webkit.org/show_bug.cgi?id=235873

Resolves #13
2022-09-19 22:17:20 +12:00
Ralph Slooten
388bea740b UI: Add documentation link (wiki) 2022-09-17 08:09:22 +12:00
Ralph Slooten
583df9ee1f Merge tag '1.1.5' into develop
Release 1.1.5
2022-09-16 23:27:57 +12:00
Ralph Slooten
8f05b97947 Merge branch 'release/1.1.5' 2022-09-16 23:27:55 +12:00
Ralph Slooten
8bdd0cc635 Release 1.1.5 2022-09-16 23:27:55 +12:00
Ralph Slooten
a372e8150e Update README 2022-09-16 23:15:40 +12:00
Ralph Slooten
2bc2660ad5 Fix count of selected messages 2022-09-16 21:54:25 +12:00
Ralph Slooten
5d6aa7c48a UI: Support for inline images using filenames instead of cid
Some historic email programs use the attachment filename instead of a reference cid for inline images (eg: Outlook).
2022-09-16 18:40:29 +12:00
Ralph Slooten
997e041042 Build: Switch to esbuild-sass-plugin 2022-09-16 14:59:28 +12:00
Ralph Slooten
5c362c1430 Merge tag '1.1.4' into develop
Release 1.1.4
2022-09-15 21:54:19 +12:00
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
38 changed files with 2455 additions and 1350 deletions

View File

@@ -2,7 +2,6 @@
Notable changes to Mailpit will be documented in this file.
{{ if .Versions -}}
{{ if .Unreleased.CommitGroups -}}
## [Unreleased]
@@ -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

@@ -42,4 +42,4 @@ jobs:
asset_name: mailpit-${{ matrix.goos }}-${{ matrix.goarch }}
extra_files: LICENSE README.md
md5sum: false
ldflags: -w -X "github.com/axllent/mailpit/cmd.Version=${{ steps.tag.outputs.tag }}"
ldflags: -w -X "github.com/axllent/mailpit/config.Version=${{ steps.tag.outputs.tag }}"

View File

@@ -24,7 +24,7 @@ jobs:
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- run: go test ./storage -v
- run: go test ./storage ./server -v
- run: go test ./storage -bench=.
# build the assets

View File

@@ -2,6 +2,88 @@
Notable changes to Mailpit will be documented in this file.
## 1.2.1
### UI
- Update frontend modules
- Add about app modal with version update notification
## 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
- Workaround for Safari source matching bug blocking event listener
### UI
- Add documentation link (wiki)
## 1.1.5
### Build
- Switch to esbuild-sass-plugin
### UI
- Support for inline images using filenames instead of cid
## 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
@@ -66,6 +148,9 @@ This release includes a major backend storage change (SQLite) that will render a
- 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

View File

@@ -8,7 +8,7 @@ WORKDIR /app
RUN apk add --no-cache git npm && \
npm install && npm run package && \
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/axllent/mailpit/cmd.Version=${VERSION}" -o /mailpit
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/axllent/mailpit/config.Version=${VERSION}" -o /mailpit
FROM alpine:latest

View File

@@ -12,14 +12,15 @@ It acts as both an SMTP server, and provides a web interface to view all capture
Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster.
![Mailpit](https://raw.githubusercontent.com/axllent/mailpit/develop/screenshot.png)
![Mailpit](https://raw.githubusercontent.com/axllent/mailpit/develop/docs/screenshot.png)
## Features
- 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)
- Advanced mail search ([see wiki](https://github.com/axllent/mailpit/wiki/Mail-search))
- 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)
@@ -29,15 +30,24 @@ 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)
## 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 +69,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

@@ -5,21 +5,11 @@ import (
"os"
"runtime"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/updater"
"github.com/spf13/cobra"
)
var (
// Version is the default application version, updated on release
Version = "dev"
// Repo on Github for updater
Repo = "axllent/mailpit"
// RepoBinaryName on Github for updater
RepoBinaryName = "mailpit"
)
// versionCmd represents the version command
var versionCmd = &cobra.Command{
Use: "version",
@@ -36,10 +26,10 @@ var versionCmd = &cobra.Command{
}
fmt.Printf("%s %s compiled with %s on %s/%s\n",
os.Args[0], Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
os.Args[0], config.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
latest, _, _, err := updater.GithubLatest(Repo, RepoBinaryName)
if err == nil && updater.GreaterThan(latest, Version) {
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
if err == nil && updater.GreaterThan(latest, config.Version) {
fmt.Printf(
"\nUpdate available: %s\nRun `%s version -u` to update (requires read/write access to install directory).\n",
latest,
@@ -59,7 +49,7 @@ func init() {
}
func updateApp() error {
rel, err := updater.GithubUpdate(Repo, RepoBinaryName, Version)
rel, err := updater.GithubUpdate(config.Repo, config.RepoBinaryName, config.Version)
if err != nil {
return err
}

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
@@ -52,6 +55,18 @@ 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';"
// Version is the default application version, updated on release
Version = "dev"
// Repo on Github for updater
Repo = "axllent/mailpit"
// RepoBinaryName on Github for updater
RepoBinaryName = "mailpit"
)
// VerifyConfig wil do some basic checking

View File

@@ -1,3 +1,4 @@
// Package data contains the message & mailbox structs
package data
import (

80
docs/apiv1/Message.md Normal file
View File

@@ -0,0 +1,80 @@
# 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.

166
docs/apiv1/Messages.md Normal file
View File

@@ -0,0 +1,166 @@
# Messages
List & delete messages.
---
## List
List messages in the mailbox. Messages are returned in the order of latest received to oldest.
**URL** : `api/v1/messages`
**Method** : `GET`
### Query parameters
| Parameter | Type | Required | Description |
|-----------|---------|----------|----------------------------|
| limit | integer | false | Limit results (default 50) |
| start | integer | false | Pagination offset |
### Response
**Status** : `200`
```json
{
"total": 500,
"unread": 500,
"count": 50,
"start": 0,
"messages": [
{
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
"Read": false,
"From": {
"Name": "John Doe",
"Address": "john@example.com"
},
"To": [
{
"Name": "Jane Smith",
"Address": "jane@example.com"
}
],
"Cc": [
{
"Name": "Accounts",
"Address": "accounts@example.com"
}
],
"Bcc": null,
"Subject": "Message subject",
"Created": "2022-10-03T21:35:32.228605299+13:00",
"Size": 6144,
"Attachments": 0
},
...
]
}
```
### Notes
- `total` - Total messages in mailbox
- `unread` - Total unread messages in mailbox
- `count` - Number of messages returned in request
- `start` - The offset (default `0`) for pagination
- `Read` - The read/unread status of the message
- `From` - Name & Address, or null if none
- `To`, `CC`, `BCC` - Array of Names & Address, or null if none
- `Created` - Local date & time the message was received
- `Size` - Total size of raw email in bytes
---
## Delete individual messages
Delete one or more messages by ID.
**URL** : `api/v1/messages`
**Method** : `DELETE`
### Request
```json
{
"ids": ["<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`

11
docs/apiv1/README.md Normal file
View File

@@ -0,0 +1,11 @@
# API v1
Mailpit provides a simple REST API to access and delete stored messages.
If the Mailpit server is set to use Basic Authentication, then API requests must use Basic Authentication too.
The API is split into three main parts:
- [Messages](Messages.md) - Listing, deleting & marking messages as read/unread.
- [Message](Message.md) - Return message data & attachments
- [Search](Search.md) - Searching messages

67
docs/apiv1/Search.md Normal file
View File

@@ -0,0 +1,67 @@
# 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

View File

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -1,6 +1,6 @@
const { build } = require('esbuild')
const pluginVue = require('esbuild-plugin-vue-next')
const sassPlugin = require("esbuild-plugin-sass");
const { sassPlugin } = require('esbuild-sass-plugin');
const doWatch = process.env.WATCH == 'true' ? true : false;
const doMinify = process.env.MINIFY == 'true' ? true : false;

2
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
@@ -44,6 +45,7 @@ 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

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)
}

12
main.go
View File

@@ -3,6 +3,7 @@ package main
import (
"os"
"path/filepath"
"strings"
"github.com/axllent/mailpit/cmd"
sendmail "github.com/axllent/mailpit/sendmail/cmd"
@@ -15,10 +16,19 @@ func main() {
}
// running directly
if filepath.Base(exec) == filepath.Base(os.Args[0]) {
if normalize(filepath.Base(exec)) == normalize(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))
}

879
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,14 +12,15 @@
"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": {
"@popperjs/core": "^2.11.5",
"@vue/compiler-sfc": "^3.2.37",
"esbuild": "^0.14.50",
"esbuild-plugin-sass": "^1.0.1",
"esbuild-plugin-vue-next": "^0.1.4"
"esbuild-plugin-vue-next": "^0.1.4",
"esbuild-sass-plugin": "^2.3.2"
}
}

View File

@@ -1,279 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"strings"
"github.com/axllent/mailpit/data"
"github.com/axllent/mailpit/server/websockets"
"github.com/axllent/mailpit/storage"
"github.com/gorilla/mux"
)
type messagesResult struct {
Total int `json:"total"`
Unread int `json:"unread"`
Count int `json:"count"`
Start int `json:"start"`
Items []data.Summary `json:"items"`
}
// Return a list of available mailboxes
func apiMailboxStats(w http.ResponseWriter, _ *http.Request) {
res := storage.StatsGet()
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// List messages
func apiListMessages(w http.ResponseWriter, r *http.Request) {
start, limit := getStartLimit(r)
messages, err := storage.List(start, limit)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet()
var res messagesResult
res.Start = start
res.Items = messages
res.Count = len(res.Items)
res.Total = stats.Total
res.Unread = stats.Unread
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// Search all messages
func apiSearchMessages(w http.ResponseWriter, r *http.Request) {
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search == "" {
fourOFour(w)
return
}
messages, err := storage.Search(search)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet()
var res messagesResult
res.Start = 0
res.Items = messages
res.Count = len(messages)
res.Total = stats.Total
res.Unread = stats.Unread
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// Open a message
func apiOpenMessage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
msg, err := storage.GetMessage(id)
if err != nil {
httpError(w, err.Error())
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)
}
// View the full email source as plain text
func apiDownloadSource(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)
}

252
server/apiv1/api.go Normal file
View File

@@ -0,0 +1,252 @@
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"`
}
// 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"))
}
// 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
}

52
server/apiv1/info.go Normal file
View File

@@ -0,0 +1,52 @@
package apiv1
import (
"encoding/json"
"net/http"
"os"
"runtime"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/updater"
)
type appVersion struct {
Version string
LatestVersion string
Database string
DatabaseSize int64
Messages int
Memory uint64
}
// AppInfo returns some basic details about the running app, and latest release.
func AppInfo(w http.ResponseWriter, r *http.Request) {
info := appVersion{}
info.Version = config.Version
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
if err == nil {
info.LatestVersion = latest
}
info.Database = config.DataFile
db, err := os.Stat(info.Database)
if err == nil {
info.DatabaseSize = db.Size()
}
info.Messages = storage.CountTotal()
var m runtime.MemStats
runtime.ReadMemStats(&m)
info.Memory = m.Sys - m.HeapReleased
bytes, _ := json.Marshal(info)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}

107
server/apiv1/thumbnails.go Normal file
View File

@@ -0,0 +1,107 @@
package apiv1
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
)
// Thumbnail returns a thumbnail image for an attachment (images only)
func Thumbnail(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
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

@@ -3,17 +3,16 @@ package server
import (
"compress/gzip"
"embed"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"strconv"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/server/apiv1"
"github.com/axllent/mailpit/server/websockets"
"github.com/gorilla/mux"
)
@@ -33,21 +32,12 @@ func Listen() {
go websockets.MessageHub.Run()
r := mux.NewRouter()
r.HandleFunc("/api/stats", middleWareFunc(apiMailboxStats)).Methods("GET")
r.HandleFunc("/api/messages", middleWareFunc(apiListMessages)).Methods("GET")
r.HandleFunc("/api/search", middleWareFunc(apiSearchMessages)).Methods("GET")
r.HandleFunc("/api/delete", middleWareFunc(apiDeleteAll)).Methods("GET")
r.HandleFunc("/api/delete", middleWareFunc(apiDeleteSelected)).Methods("POST")
r := defaultRoutes()
// web UI websocket
r.HandleFunc("/api/events", apiWebsocket).Methods("GET")
r.HandleFunc("/api/read", apiMarkAllRead).Methods("GET")
r.HandleFunc("/api/read", apiMarkSelectedRead).Methods("POST")
r.HandleFunc("/api/unread", apiMarkSelectedUnread).Methods("POST")
r.HandleFunc("/api/{id}/source", middleWareFunc(apiDownloadSource)).Methods("GET")
r.HandleFunc("/api/{id}/part/{partID}", middleWareFunc(apiDownloadAttachment)).Methods("GET")
r.HandleFunc("/api/{id}/delete", middleWareFunc(apiDeleteOne)).Methods("GET")
r.HandleFunc("/api/{id}/unread", middleWareFunc(apiUnreadOne)).Methods("GET")
r.HandleFunc("/api/{id}", middleWareFunc(apiOpenMessage)).Methods("GET")
// virtual filesystem for others
r.PathPrefix("/").Handler(middlewareHandler(http.FileServer(http.FS(serverRoot))))
http.Handle("/", r)
@@ -64,6 +54,23 @@ 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")
r.HandleFunc("/api/v1/info", middleWareFunc(apiv1.AppInfo)).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"`)
@@ -84,6 +91,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", config.ContentSecurityPolicy)
if config.UIAuthFile != "" {
user, pass, ok := r.BasicAuth()
@@ -114,6 +124,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", config.ContentSecurityPolicy)
if config.UIAuthFile != "" {
user, pass, ok := r.BasicAuth()
@@ -140,34 +152,7 @@ func middlewareHandler(h http.Handler) http.Handler {
})
}
// FourOFour returns a basic 404 message
func fourOFour(w http.ResponseWriter) {
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.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, msg)
}
// Get the start and limit based on query params. Defaults to 0, 50
func getStartLimit(req *http.Request) (start int, limit int) {
start = 0
limit = 50
s := req.URL.Query().Get("start")
if n, err := strconv.Atoi(s); err == nil && n > 0 {
start = n
}
l := req.URL.Query().Get("limit")
if n, err := strconv.Atoi(l); err == nil && n > 0 {
limit = n
}
return start, limit
// Websocket to broadcast changes
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
websockets.ServeWs(websockets.MessageHub, w, r)
}

305
server/server_test.go Normal file
View File

@@ -0,0 +1,305 @@
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)
}

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,9 +23,13 @@ export default {
isConnected: false,
scrollInPlace: false,
message: false,
messagePrev: false,
messageNext: false,
notificationsSupported: false,
notificationsEnabled: false,
selected: []
selected: [],
tcStatus: 0,
appInfo : false,
}
},
watch: {
@@ -34,6 +39,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: {
@@ -54,8 +70,14 @@ export default {
&& ("Notification" in window && Notification.permission !== "denied");
this.notificationsEnabled = this.notificationsSupported && Notification.permission == "granted";
this.connect();
Tinycon.setOptions({
height: 11,
background: '#dd0000',
fallback: false
});
this.loadMessages();
this.connect();
},
methods: {
loadMessages: function () {
@@ -63,11 +85,11 @@ export default {
let params = {};
this.selected = [];
let uri = 'api/messages';
let uri = 'api/v1/messages';
if (self.search) {
self.searching = true;
self.items = [];
uri = 'api/search'
uri = 'api/v1/search'
self.start = 0; // search is displayed on one page
params['query'] = self.search;
} else {
@@ -83,7 +105,12 @@ export default {
self.unread = response.data.unread;
self.count = response.data.count;
self.start = response.data.start;
self.items = response.data.items;
self.items = response.data.messages;
if (self.items == 0 && self.start > 0) {
self.start = 0;
return self.loadMessages();
}
if (!self.scrollInPlace) {
let mp = document.getElementById('message-page');
@@ -132,7 +159,7 @@ export default {
let self = this;
self.selected = [];
let uri = 'api/' + self.currentPath
let uri = 'api/v1/message/' + self.currentPath
self.get(uri, false, function(response) {
for (let i in self.items) {
if (self.items[i].ID == self.currentPath) {
@@ -150,7 +177,14 @@ export default {
if (a.ContentID != '') {
d.HTML = d.HTML.replace(
new RegExp('cid:'+a.ContentID, 'g'),
window.location.origin+'/api/'+d.ID+'/part/'+a.PartID
window.location.origin+'/api/v1/message/'+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+'"'
);
}
}
@@ -162,58 +196,73 @@ export default {
if (a.ContentID != '') {
d.HTML = d.HTML.replace(
new RegExp('cid:'+a.ContentID, 'g'),
window.location.origin+'/api/'+d.ID+'/part/'+a.PartID
window.location.origin+'/api/v1/message/'+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+'"'
);
}
}
}
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;
}
}
});
},
// universal handler to delete current or selected messages
deleteMessages: function() {
let ids = [];
let self = this;
if (self.message) {
ids.push(self.message.ID);
} else {
ids = JSON.parse(JSON.stringify(self.selected));
}
if (!ids.length) {
return false;
}
let uri = 'api/v1/messages';
self.delete(uri, {'ids': ids}, function(response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
});
},
deleteAll: function() {
let self = this;
let uri = 'api/delete'
self.get(uri, false, function(response) {
let uri = 'api/v1/messages';
self.delete(uri, false, function(response) {
window.location.hash = "";
self.reloadMessages();
});
},
deleteOne: function() {
let self = this;
if (!self.message) {
return false;
}
let uri = 'api/' + self.message.ID + '/delete'
self.get(uri, false, function(response) {
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();
});
},
markUnread: function() {
let self = this;
if (!self.message) {
return false;
}
let uri = 'api/' + self.message.ID + '/unread'
self.get(uri, false, function(response) {
let uri = 'api/v1/messages';
self.put(uri, {'read': false, 'ids': [self.message.ID]}, function(response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
@@ -222,8 +271,8 @@ export default {
markAllRead: function() {
let self = this;
let uri = 'api/read'
self.get(uri, false, function(response) {
let uri = 'api/v1/messages'
self.put(uri, {'read': true}, function(response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
@@ -235,8 +284,8 @@ export default {
if (!self.selected.length) {
return false;
}
let uri = 'api/read'
self.post(uri, {'ids': self.selected}, function(response) {
let uri = 'api/v1/messages';
self.put(uri, {'read': true, 'ids': self.selected}, function(response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
@@ -248,18 +297,46 @@ export default {
if (!self.selected.length) {
return false;
}
let uri = 'api/unread'
self.post(uri, {'ids': self.selected}, function(response) {
let uri = 'api/v1/messages';
self.put(uri, {'read': false, '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';
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);
@@ -280,7 +357,8 @@ export default {
}
self.total++;
self.unread++;
self.browserNotify("New mail from: " + response.Data.From.Address, response.Data.Subject);
let from = response.Data.From != null ? response.Data.From.Address : '[unknown]';
self.browserNotify("New mail from: " + from, response.Data.Subject);
} else if (response.Type == "prune") {
// messages have been deleted, reload messages to adjust
self.scrollInPlace = true;
@@ -344,7 +422,7 @@ export default {
else if (Notification.permission !== "denied") {
let self = this;
Notification.requestPermission().then(function (permission) {
// If the user accepts, let's create a notification
// if the user accepts, let's create a notification
if (permission === "granted") {
self.browserNotify("Notifications enabled", "You will receive notifications when new mails are received.");
self.notificationsEnabled = true;
@@ -370,6 +448,12 @@ export default {
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);
@@ -378,13 +462,17 @@ export default {
for (let d of this.items) {
if (selecting) {
this.selected.push(d.ID);
if (!this.isSelected(d.ID)) {
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);
if (!this.isSelected(d.ID)) {
this.selected.push(d.ID);
}
selecting = true;
}
}
@@ -392,6 +480,15 @@ export default {
isSelected: function(id) {
return this.selected.indexOf(id) != -1;
},
loadInfo: function(e) {
e.preventDefault();
let self = this;
self.get('api/v1/info', false, function(response) {
self.appInfo = response.data;
self.modal('AppInfoModal').show();
});
}
}
}
@@ -410,13 +507,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>
<a :href="'api/' + message.ID + '/source?dl=1'" class="btn btn-outline-secondary me-2 float-end" title="Download message">
<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">
<i class="bi bi-file-arrow-down-fill"></i> <span class="d-none d-md-inline">Download</span>
</a>
</div>
@@ -459,11 +562,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>
@@ -485,18 +587,18 @@ 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 && !selected.length">
<li class="my-3" v-if="!message && 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="total && !selected.length">
<li class="my-3" v-if="!message && 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
@@ -507,20 +609,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="unread && selected.length > 0">
<li class="my-3 ms-2" v-if="selected.length > 0 && selectedHasUnread()">
<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">
<li class="my-3 ms-2" v-if="selected.length > 0 && selectedHasRead()">
<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">
<a href="#" v-on:click="deleteMessages">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete
</a>
@@ -532,10 +634,10 @@ export default {
Enable alerts
</a>
</li>
<li class="mt-5 position-fixed bottom-0">
<a href="https://github.com/axllent/mailpit" target="_blank" class="text-muted w-100 d-block bg-white my-3">
<i class="bi bi-github"></i>
GitHub
<li class="mt-5 position-fixed bottom-0 bg-white py-2 text-muted">
<a href="#" class="text-muted" v-on:click="loadInfo">
<i class="bi bi-info-circle-fill"></i>
About
</a>
</li>
</ul>
@@ -546,14 +648,13 @@ export default {
<div class="list-group" v-if="items.length">
<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"
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>
@@ -661,4 +762,59 @@ export default {
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="AppInfoModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header" v-if="appInfo">
<h5 class="modal-title" id="AppInfoModalLabel">
Mailpit
<code>({{ appInfo.Version }})</code>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<a class="btn btn-warning d-block mb-3" v-if="appInfo.Version != appInfo.LatestVersion"
:href="'https://github.com/axllent/mailpit/releases/tag/'+appInfo.LatestVersion">
A new version of Mailpit ({{ appInfo.LatestVersion }}) is available.
</a>
<div class="row g-3">
<div class="col-sm-6">
<a class="btn btn-primary w-100" href="https://github.com/axllent/mailpit" target="_blank">
<i class="bi bi-github"></i>
Github
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
<div class="col-sm-6">
<a class="btn btn-primary w-100" href="https://github.com/axllent/mailpit/wiki" target="_blank">
Documentation
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
<div class="col-sm-6">
<div class="card border-secondary text-center">
<div class="card-header">Database size</div>
<div class="card-body text-secondary">
<h5 class="card-title">{{ getFileSize(appInfo.DatabaseSize) }} </h5>
</div>
</div>
</div>
<div class="col-sm-6">
<div class="card border-secondary text-center">
<div class="card-header">RAM usage</div>
<div class="card-body text-secondary">
<h5 class="card-title">{{ getFileSize(appInfo.Memory) }} </h5>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</template>

View File

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

View File

@@ -38,14 +38,14 @@
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;
}
@@ -95,3 +95,192 @@ body.blur {
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

@@ -1,4 +1,6 @@
import axios from 'axios'
import axios from 'axios';
import { Modal } from 'bootstrap';
// FakeModal is used to return a fake Bootstrap modal
// if the ID returns nothing
@@ -8,128 +10,205 @@ 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 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;
self.loading++;
axios.post(url, values)
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
}
});
},
/**
* Axios POST request
*
* @params string url
* @params array object/array values
* @params function callback function
*/
post: function (url, data, callback) {
let self = this;
self.loading++;
axios.post(url, data)
.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 object/array values
* @params function callback function
*/
delete: function (url, data, 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)
.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';
}
}
}
export default commonMixins
export default commonMixins

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/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="">
<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/v1/message/' + 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,74 +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-if="message.To" v-for="(t, i) in message.To">
<template v-if="i > 0">,</template>
{{ t.Name + " <" + t.Address +">" }}
</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>
</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/v1/message/'+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.HTML">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">
@@ -168,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.HTML">
<pre><code class="language-html">{{ message.HTML }}</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

@@ -1,97 +1 @@
<?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>
<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>

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 655 B

View File

@@ -95,6 +95,8 @@ func InitDB() error {
p = filepath.Clean(p)
}
config.DataFile = p
logger.Log().Debugf("[db] opening database %s", p)
var err error
@@ -387,6 +389,7 @@ 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 != "" {
@@ -406,8 +409,6 @@ func GetMessage(id string) (*data.Message, error) {
}
}
obj.HTML = html
// mark message as read
if err := MarkRead(id); err != nil {
return &obj, err
@@ -510,6 +511,7 @@ func MarkAllRead() error {
_, err := sqlf.Update("mailbox").
Set("Read", 1).
Where("Read = ?", 0).
ExecAndClose(context.Background(), db)
if err != nil {
return err
@@ -523,6 +525,29 @@ func MarkAllRead() error {
return nil
}
// MarkAllUnread will mark all messages as unread
func MarkAllUnread() error {
var (
start = time.Now()
total = CountRead()
)
_, err := sqlf.Update("mailbox").
Set("Read", 0).
Where("Read = ?", 1).
ExecAndClose(context.Background(), db)
if err != nil {
return err
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] marked %d messages as unread in %s", total, elapsed)
dbLastAction = time.Now()
return nil
}
// MarkUnread will mark a message as unread
func MarkUnread(id string) error {
if IsUnread(id) {
@@ -654,7 +679,6 @@ func CountTotal() int {
}
// CountUnread returns the number of emails in the database that are unread.
// If an ID is supplied, then it is just limited to that message.
func CountUnread() int {
var total int
@@ -667,6 +691,19 @@ func CountUnread() int {
return total
}
// CountRead returns the number of emails in the database that are read.
func CountRead() int {
var total int
q := sqlf.From("mailbox").
Select("COUNT(*)").To(&total).
Where("Read = ?", 1)
_ = q.QueryRowAndClose(nil, db)
return total
}
// IsUnread returns the number of emails in the database that are unread.
// If an ID is supplied, then it is just limited to that message.
func IsUnread(id string) bool {

View File

@@ -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)
}