Compare commits

..

76 Commits

Author SHA1 Message Date
Ralph Slooten
1f7a60452e Merge branch 'release/v1.19.2' 2024-07-21 16:11:49 +12:00
Ralph Slooten
14943324e8 Release v1.19.2 2024-07-21 16:11:48 +12:00
Ralph Slooten
b05c6fbf60 Chore: Update Go dependencies 2024-07-21 16:06:26 +12:00
Ralph Slooten
21a6f798d1 Fix: Update Inbox "Delete All" count when new messages are detected (#334) 2024-07-16 16:21:49 +12:00
Ralph Slooten
9014376e80 Merge tag 'v1.19.1' into develop
Release v1.19.1
2024-07-14 15:13:38 +12:00
Ralph Slooten
609b2a64ea Merge branch 'release/v1.19.1' 2024-07-14 15:13:34 +12:00
Ralph Slooten
eb120a231b Release v1.19.1 2024-07-14 15:13:33 +12:00
Ralph Slooten
fd03926260 Chore: Update Go dependencies 2024-07-14 15:08:28 +12:00
Ralph Slooten
6947c2a621 Feature: Add optional relay recipient blocklist (#333) 2024-07-14 15:04:36 +12:00
Ralph Slooten
406fe56fc6 Chore: Equal column widths in About modal 2024-07-07 22:17:21 +12:00
Ralph Slooten
13a418370f Chore: Bump esbuild to version 0.23.0 2024-07-02 19:17:16 +12:00
dependabot[bot]
80a2ab68c2 Chore: Bump esbuild from 0.21.5 to 0.22.0 (#326) 2024-07-01 21:55:50 +12:00
dependabot[bot]
1d9c12b657 Chore: Bump docker/build-push-action from 5 to 6 (#327) 2024-07-01 21:53:29 +12:00
Ralph Slooten
a1b1e97f75 Merge tag 'v1.19.0' into develop
Release v1.19.0
2024-06-29 23:01:55 +12:00
Ralph Slooten
61e8cad507 Merge branch 'release/v1.19.0' 2024-06-29 23:01:51 +12:00
Ralph Slooten
1f0f9efa7a Release v1.19.0 2024-06-29 23:01:49 +12:00
Ralph Slooten
f5f2371839 Merge branch 'feature/tag-edit' into develop 2024-06-29 17:24:07 +12:00
Ralph Slooten
3fcbdb3273 Chore: Update node dependencies 2024-06-29 17:23:07 +12:00
Ralph Slooten
52d8806c01 Chore: Update Go dependencies 2024-06-29 17:17:18 +12:00
Ralph Slooten
b941015632 Consolidate API tag functionality 2024-06-29 17:15:21 +12:00
Ralph Slooten
0c377b9616 Feature: Add ability to rename and delete tags globally 2024-06-29 17:12:56 +12:00
Ralph Slooten
0dca8df29c Feature: Add option to disable auto-tagging for plus-addresses & X-Tags (#323) 2024-06-28 22:35:07 +12:00
Ralph Slooten
c7e0455479 Handle errors correctly 2024-06-22 23:56:17 +12:00
Ralph Slooten
19645db2de Use correct AS case 2024-06-22 23:48:07 +12:00
Ralph Slooten
6373a33bff Merge tag 'v1.18.7' into develop
Release v1.18.7
2024-06-22 23:36:20 +12:00
Ralph Slooten
9a3d0ca337 Merge branch 'release/v1.18.7' 2024-06-22 23:36:12 +12:00
Ralph Slooten
4193489b9e Release v1.18.7 2024-06-22 23:36:09 +12:00
Ralph Slooten
eac0b9d5df Add nosec to audited code 2024-06-22 23:34:18 +12:00
Ralph Slooten
e60fefb33b Update Changelog group title order 2024-06-22 23:07:24 +12:00
Ralph Slooten
0bc8dcc161 Use short hash in edge builds 2024-06-22 13:48:10 +12:00
Ralph Slooten
99c5c1a120 Use short hash in edge builds 2024-06-22 13:46:50 +12:00
Ralph Slooten
33e367d706 Chore: Refactor JavaScript, use arrow functions instead of "self" aliasing 2024-06-22 13:27:00 +12:00
Ralph Slooten
5e5b855a3d UI tweaks 2024-06-22 12:12:18 +12:00
Ralph Slooten
e15a8fecc5 Chore: Handle websocket errors caused by persistent connection failures (#319)
When either websockets do not work, or when they continually break connection (>3 / 15s), websockets will now stop reconnecting.
2024-06-22 12:07:01 +12:00
Ralph Slooten
eb0ef8baff Merge branch 'feature/label' into develop 2024-06-21 16:54:43 +12:00
Ralph Slooten
a155b395db Feature: Add optional label to identify Mailpit instance (#316) 2024-06-21 16:54:33 +12:00
Ralph Slooten
8de2c5ec81 Template formatting 2024-06-21 16:09:48 +12:00
Ralph Slooten
7a55e4d0e2 Enable POP3 integration tests 2024-06-21 15:40:22 +12:00
Ralph Slooten
f7f200c6fe Testing: Add POP3 integration tests 2024-06-21 15:38:30 +12:00
Ralph Slooten
1bd6794b2d Merge tag 'v1.18.6' into develop
Release v1.18.6
2024-06-19 16:25:14 +12:00
Ralph Slooten
7204964cf8 Merge branch 'release/v1.18.6' 2024-06-19 16:25:13 +12:00
Ralph Slooten
a4b081f9b9 Release v1.18.6 2024-06-19 16:25:12 +12:00
Ralph Slooten
1529e424f8 Merge branch 'feature/pop3-fixes' into develop 2024-06-19 16:20:02 +12:00
Ralph Slooten
48045ec0aa Chore: Update caniemail database 2024-06-19 16:18:42 +12:00
Ralph Slooten
545162e6fc Chore: Update node dependencies 2024-06-19 16:17:54 +12:00
Ralph Slooten
d2f586c133 Chore: Update Go dependencies 2024-06-19 16:14:29 +12:00
Ralph Slooten
2cf0b50d1b Rename pop3 server file 2024-06-19 16:10:03 +12:00
Ralph Slooten
70baf12adb Chore: Delete multiple POP3 messages in single action 2024-06-19 16:02:40 +12:00
Ralph Slooten
710f093561 Use consistent POP3 response casing 2024-06-19 15:59:55 +12:00
Ralph Slooten
b7ad94211b Chore: Handle POP3 RSET command 2024-06-19 15:59:18 +12:00
Ralph Slooten
7991c49312 Ensure a user has been set first before a password can be issued 2024-06-19 15:47:05 +12:00
Ralph Slooten
7773c6b04c Commands in the POP3 are case-insensitive (see RFC1939) 2024-06-19 15:46:38 +12:00
Antonio Nardella
a32237e14f Fix: POP3 end of file reached error (#315)
* Changed POP3 size output to show compatible size

* Setting POP3 10 minutes timeout according to RFC1939

* fixed issue with unauthorized commands access, refactor

* readded package description

* fixes error strings should not be capitalized (ST1005)go-staticcheck
2024-06-19 15:34:40 +12:00
Antonio Nardella
ce7dcce61c Fix: POP3 size output to show compatible sizes (#312)
* Changed POP3 size output to show compatible size

* Setting POP3 10 minutes timeout according to RFC1939
2024-06-15 08:50:22 +12:00
Ralph Slooten
83c94c879a Merge tag 'v1.18.5' into develop
Release v1.18.5
2024-06-07 14:20:07 +12:00
Ralph Slooten
029db4bc00 Merge branch 'release/v1.18.5' 2024-06-07 14:20:05 +12:00
Ralph Slooten
b595af6b72 Release v1.18.5 2024-06-07 14:20:05 +12:00
Ralph Slooten
79e1f9d773 Chore: Update node dependencies 2024-06-07 14:13:24 +12:00
Ralph Slooten
28a8502a65 Chore: Update Go dependencies 2024-06-07 14:11:48 +12:00
Ralph Slooten
7105450cc7 Correctly handle browser back/forward navigation with pagination 2024-06-07 14:05:50 +12:00
Ralph Slooten
8a6d71ed9c Merge branch 'feature/query-parameters' into develop 2024-06-06 16:14:24 +12:00
Ralph Slooten
aa3f94457c Improve pagination & limit URL parameter handling 2024-06-02 16:07:26 +12:00
Yuuki Takahashi
e87b98b73b Feature: Add pagination & limits to URL parameters (#303)
* Set search conditions to query parameters

* Fixed by review

* Update query parameters when new message notified
2024-06-02 15:37:38 +12:00
Ralph Slooten
21eef69a60 Merge tag 'v1.18.4' into develop
Release v1.18.4
2024-06-01 22:48:52 +12:00
Ralph Slooten
1fb869fb5e Merge branch 'release/v1.18.4' 2024-06-01 22:48:47 +12:00
Ralph Slooten
31390e4b82 Set booxmedialtd/ws-action-parse-semver action version 2024-06-01 22:46:46 +12:00
Ralph Slooten
3974fdfbaf Merge tag 'v1.18.4' into develop
Release v1.18.4
2024-06-01 22:35:38 +12:00
Ralph Slooten
9909fd969c Merge branch 'release/v1.18.4' 2024-06-01 22:35:36 +12:00
Ralph Slooten
abd1f0b008 Release v1.18.4 2024-06-01 22:35:34 +12:00
Ralph Slooten
0dbbb821eb Chore: Update node dependencies 2024-06-01 22:28:41 +12:00
Ralph Slooten
262be51c9b Minor change to timezone dropdown 2024-06-01 22:27:40 +12:00
Ralph Slooten
5dee4cc763 Chore: Update Go dependencies 2024-06-01 22:25:42 +12:00
Ralph Slooten
f89fa46902 Chore: Clone new Docker images to ghcr.io (#302)
This is for convenience, and the primary Docker registry remains on https://hub.docker.com/r/axllent/mailpit
2024-05-26 19:27:00 +12:00
Ralph Slooten
c25dee57c3 Test ghcr.io packages 2024-05-26 18:38:19 +12:00
Ralph Slooten
e192d5efd2 Add dot-stuffing POP3 comment & RFC link 2024-05-19 00:42:52 +12:00
Ralph Slooten
0de93c7868 Merge tag 'v1.18.3' into develop
Release v1.18.3
2024-05-18 23:56:46 +12:00
64 changed files with 3595 additions and 1866 deletions

View File

@@ -17,6 +17,24 @@ options:
fix: Fix
# perf: Performance Improvements
# refactor: Code Refactoring
sort_by: Custom
title_order:
- Feature
- Chore
- UI
- API
- Libs
- Docker
- Security
- Fix
- Bugfix
- Docs
- Swagger
- Build
- Testing
- Test
- Tests
- Pull Requests
header:
pattern: "^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\:\\s(.*)$"
pattern_maps:

View File

@@ -16,19 +16,30 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
- name: Log into Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- name: Log into GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ github.token }}
- uses: benjlevesque/short-sha@v3.0
id: short-sha
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/386,linux/amd64,linux/arm64
build-args: |
"VERSION=edge-${{ github.sha }}"
"VERSION=edge-${{ steps.short-sha.outputs.sha }}"
push: true
tags: |
axllent/mailpit:edge
ghcr.io/${{ github.repository }}:edge

View File

@@ -16,12 +16,19 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
- name: Log into Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- name: Log into GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ github.token }}
- name: Parse semver
id: semver_parser
uses: booxmedialtd/ws-action-parse-semver@v1.4.7
@@ -30,10 +37,9 @@ jobs:
version_extractor_regex: 'v(.*)$'
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
# platforms: linux/386,linux/amd64,linux/arm,linux/arm64
platforms: linux/386,linux/amd64,linux/arm64
build-args: |
"VERSION=${{ github.ref_name }}"
@@ -42,3 +48,6 @@ jobs:
axllent/mailpit:latest
axllent/mailpit:${{ github.ref_name }}
axllent/mailpit:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
ghcr.io/${{ github.repository }}:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
FROM golang:alpine as builder
FROM golang:alpine AS builder
ARG VERSION=dev

View File

@@ -82,6 +82,7 @@ func init() {
initConfigFromEnv()
rootCmd.Flags().StringVarP(&config.Database, "database", "d", config.Database, "Database to store persistent data")
rootCmd.Flags().StringVar(&config.Label, "label", config.Label, "Optional label identify this Mailpit instance")
rootCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data")
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
rootCmd.Flags().BoolVar(&config.UseMessageDates, "use-message-dates", config.UseMessageDates, "Use message dates as the received dates")
@@ -130,6 +131,7 @@ func init() {
rootCmd.Flags().StringVarP(&config.CLITagsArg, "tag", "t", config.CLITagsArg, "Tag new messages matching filters")
rootCmd.Flags().StringVar(&config.TagsConfig, "tags-config", config.TagsConfig, "Load tags filters from yaml configuration file")
rootCmd.Flags().BoolVar(&tools.TagsTitleCase, "tags-title-case", tools.TagsTitleCase, "TitleCase new tags generated from plus-addresses and X-Tags")
rootCmd.Flags().StringVar(&config.TagsDisable, "tags-disable", config.TagsDisable, "Disable auto-tagging, comma separated (eg: plus-addresses,x-tags)")
// Webhook
rootCmd.Flags().StringVar(&config.WebhookURL, "webhook-url", config.WebhookURL, "Send a webhook request for new messages")
@@ -172,6 +174,8 @@ func initConfigFromEnv() {
config.TenantID = os.Getenv("MP_TENANT_ID")
config.Label = os.Getenv("MP_LABEL")
if len(os.Getenv("MP_MAX_MESSAGES")) > 0 {
config.MaxMessages, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGES"))
}
@@ -271,6 +275,7 @@ func initConfigFromEnv() {
config.SMTPRelayConfig.Secret = os.Getenv("MP_SMTP_RELAY_SECRET")
config.SMTPRelayConfig.ReturnPath = os.Getenv("MP_SMTP_RELAY_RETURN_PATH")
config.SMTPRelayConfig.AllowedRecipients = os.Getenv("MP_SMTP_RELAY_ALLOWED_RECIPIENTS")
config.SMTPRelayConfig.BlockedRecipients = os.Getenv("MP_SMTP_RELAY_BLOCKED_RECIPIENTS")
// POP3 server
if len(os.Getenv("MP_POP3_BIND_ADDR")) > 0 {
@@ -287,6 +292,7 @@ func initConfigFromEnv() {
config.CLITagsArg = os.Getenv("MP_TAG")
config.TagsConfig = os.Getenv("MP_TAGS_CONFIG")
tools.TagsTitleCase = getEnabledFromEnv("MP_TAGS_TITLE_CASE")
config.TagsDisable = os.Getenv("MP_TAGS_DISABLE")
// Webhook
if len(os.Getenv("MP_WEBHOOK_URL")) > 0 {

View File

@@ -15,6 +15,7 @@ import (
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/spamassassin"
"github.com/axllent/mailpit/internal/tools"
"gopkg.in/yaml.v3"
)
@@ -32,6 +33,10 @@ var (
// allowing multiple isolated instances of Mailpit to share a database.
TenantID = ""
// Label to identify this Mailpit instance (optional).
// This gets applied to web UI, SMTP and optional POP3 server.
Label = ""
// MaxMessages is the maximum number of messages a mailbox can have (auto-pruned every minute)
MaxMessages = 500
@@ -97,6 +102,10 @@ var (
// TagFilters are used to apply tags to new mail
TagFilters []autoTag
// TagsDisable accepts a comma-separated list of tag types to disable
// including x-tags & plus-addresses
TagsDisable string
// SMTPRelayConfigFile to parse a yaml file and store config of relay SMTP server
SMTPRelayConfigFile string
@@ -182,6 +191,9 @@ type SMTPRelayConfigStruct struct {
ReturnPath string `yaml:"return-path"` // allow overriding the bounce address
AllowedRecipients string `yaml:"allowed-recipients"` // regex, if set needs to match for mails to be relayed
AllowedRecipientsRegexp *regexp.Regexp // compiled regexp using AllowedRecipients
BlockedRecipients string `yaml:"blocked-recipients"` // regex, if set prevents relating to these addresses
BlockedRecipientsRegexp *regexp.Regexp // compiled regexp using BlockedRecipients
// DEPRECATED 2024/03/12
RecipientAllowlist string `yaml:"recipient-allowlist"`
}
@@ -201,7 +213,9 @@ func VerifyConfig() error {
Database = filepath.Join(Database, "mailpit.db")
}
TenantID = strings.TrimSpace(TenantID)
Label = tools.Normalize(Label)
TenantID = tools.Normalize(TenantID)
if TenantID != "" {
logger.Log().Infof("[db] using tenant \"%s\"", TenantID)
re := regexp.MustCompile(`[^a-zA-Z0-9\_]`)
@@ -383,7 +397,7 @@ func VerifyConfig() error {
}
}
// load tag filters
// load tag filters & options
TagFilters = []autoTag{}
if err := loadTagsFromArgs(CLITagsArg); err != nil {
return err
@@ -391,6 +405,9 @@ func VerifyConfig() error {
if err := loadTagsFromConfig(TagsConfig); err != nil {
return err
}
if err := parseTagsDisable(TagsDisable); err != nil {
return err
}
if SMTPAllowedRecipients != "" {
restrictRegexp, err := regexp.Compile(SMTPAllowedRecipients)
@@ -419,12 +436,12 @@ func VerifyConfig() error {
if SMTPRelayAll {
logger.Log().Warnf("[relay] ignoring smtp-relay-matching when smtp-relay-all is enabled")
} else {
restrictRegexp, err := regexp.Compile(SMTPRelayMatching)
re, err := regexp.Compile(SMTPRelayMatching)
if err != nil {
return fmt.Errorf("[relay] failed to compile smtp-relay-matching regexp: %s", err.Error())
}
SMTPRelayMatchingRegexp = restrictRegexp
SMTPRelayMatchingRegexp = re
logger.Log().Infof("[relay] auto-relaying new messages to recipients matching \"%s\" via %s:%d",
SMTPRelayMatching, SMTPRelayConfig.Host, SMTPRelayConfig.Port)
}
@@ -511,14 +528,23 @@ func validateRelayConfig() error {
logger.Log().Infof("[smtp] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
if SMTPRelayConfig.AllowedRecipients != "" {
allowlistRegexp, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients)
re, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients)
if err != nil {
return fmt.Errorf("[smtp] failed to compile relay recipient allowlist regexp: %s", err.Error())
}
SMTPRelayConfig.AllowedRecipientsRegexp = allowlistRegexp
SMTPRelayConfig.AllowedRecipientsRegexp = re
logger.Log().Infof("[smtp] relay recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.AllowedRecipients)
}
if SMTPRelayConfig.BlockedRecipients != "" {
re, err := regexp.Compile(SMTPRelayConfig.BlockedRecipients)
if err != nil {
return fmt.Errorf("[smtp] failed to compile relay recipient blocklist regexp: %s", err.Error())
}
SMTPRelayConfig.BlockedRecipientsRegexp = re
logger.Log().Infof("[smtp] relay recipient blocklist is active with the following regexp: %s", SMTPRelayConfig.BlockedRecipients)
}
return nil

View File

@@ -11,6 +11,14 @@ import (
"gopkg.in/yaml.v3"
)
var (
// TagsDisablePlus disables message tagging using plus-addresses (user+tag@example.com) - set via verifyConfig()
TagsDisablePlus bool
// TagsDisableXTags disables message tagging via the X-Tags header - set via verifyConfig()
TagsDisableXTags bool
)
type yamlTags struct {
Filters []yamlTag `yaml:"filters"`
}
@@ -79,3 +87,25 @@ func loadTagsFromArgs(c string) error {
return nil
}
func parseTagsDisable(s string) error {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
parts := strings.Split(strings.ToLower(s), ",")
for _, p := range parts {
switch strings.TrimSpace(p) {
case "x-tags", "xtags":
TagsDisableXTags = true
case "plus-addresses", "plus-addressing":
TagsDisablePlus = true
default:
return fmt.Errorf("[tags] invalid --tags-disable option: %s", p)
}
}
return nil
}

22
go.mod
View File

@@ -8,11 +8,11 @@ require (
github.com/PuerkitoBio/goquery v1.9.2
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/axllent/semver v0.0.1
github.com/gomarkdown/markdown v0.0.0-20240419095408-642f0ee99ae2
github.com/gomarkdown/markdown v0.0.0-20240626202925-2eda941fd024
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.1
github.com/gorilla/websocket v1.5.3
github.com/jhillyerd/enmime v1.2.0
github.com/klauspost/compress v1.17.8
github.com/klauspost/compress v1.17.9
github.com/kovidgoyal/imaging v1.6.3
github.com/leporo/sqlf v1.4.0
github.com/lithammer/shortuuid/v4 v4.0.0
@@ -20,15 +20,15 @@ require (
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e
github.com/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.8.0
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
github.com/tg123/go-htpasswd v1.2.2
github.com/vanng822/go-premailer v1.21.0
golang.org/x/net v0.25.0
golang.org/x/text v0.15.0
golang.org/x/net v0.27.0
golang.org/x/text v0.16.0
golang.org/x/time v0.5.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.29.10
modernc.org/sqlite v1.30.2
)
require (
@@ -54,12 +54,12 @@ require (
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/vanng822/css v1.0.1 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/image v0.16.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/crypto v0.25.0 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/sys v0.22.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
modernc.org/libc v1.50.7 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect

64
go.sum
View File

@@ -11,7 +11,7 @@ github.com/axllent/semver v0.0.1 h1:QqF+KSGxgj8QZzSXAvKFqjGWE5792ksOnQhludToK8E=
github.com/axllent/semver v0.0.1/go.mod h1:2xSPzvG8n9mRfdtxSvWvfTfQGWfHsMsHO1iZnKATMSc=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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=
@@ -23,8 +23,8 @@ github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/gomarkdown/markdown v0.0.0-20240419095408-642f0ee99ae2 h1:yEt5djSYb4iNtmV9iJGVday+i4e9u6Mrn5iP64HH5QM=
github.com/gomarkdown/markdown v0.0.0-20240419095408-642f0ee99ae2/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20240626202925-2eda941fd024 h1:saBP362Qm7zDdDXqv61kI4rzhmLFq3Z1gx34xpl6cWE=
github.com/gomarkdown/markdown v0.0.0-20240626202925-2eda941fd024/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -34,8 +34,8 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -44,8 +44,8 @@ github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQykt
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v1.2.0 h1:dIu1IPEymQgoT2dzuB//ttA/xcV40NMPpQtmd4wslHk=
github.com/jhillyerd/enmime v1.2.0/go.mod h1:FRFuUPCLh8PByQv+8xRcLO9QHqaqTqreYhopv5eyk4I=
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kovidgoyal/imaging v1.6.3 h1:iNPpv7ygiaB/NOztc6APMT7yr9UwBS+rOZwIbAdtyY8=
github.com/kovidgoyal/imaging v1.6.3/go.mod h1:sHvcLOOVhJuto2IoNdPLEqnAUoL5ZfHEF0PpNH+882g=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -95,8 +95,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
@@ -126,14 +126,14 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw=
golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -142,11 +142,13 @@ golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -159,8 +161,8 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -174,16 +176,16 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -193,18 +195,18 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.21.2 h1:dycHFB/jDc3IyacKipCNSDrjIC0Lm1hyoWOZTRR20Lk=
modernc.org/cc/v4 v4.21.2/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.17.7 h1:+MG+Np7uYtsuPvtoH3KtZ1+pqNiJAOqqqVIxggE1iIo=
modernc.org/ccgo/v4 v4.17.7/go.mod h1:x87xuLLXuJv3Nn5ULTUqJn/HsTMMMiT1Eavo6rz1NiY=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.50.7 h1:25+61e/ZI1e53ynk8dvS/BvWie3lIJPR1KVlTdGkkCg=
modernc.org/libc v1.50.7/go.mod h1:8lr2m1THY5Z3ikGyUc3JhLEQg1oaIBz/AQixw8/eksQ=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
@@ -213,8 +215,8 @@ modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg=
modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
modernc.org/sqlite v1.30.2 h1:IPVVkhLu5mMVnS1dQgh3h0SAACRWcVk7aoLP9Us3UCk=
modernc.org/sqlite v1.30.2/go.mod h1:DUmsiWQDaAvU4abhc/N+djlom/L2o8f7gZ95RCvyoLU=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -1,6 +1,6 @@
{
"api_version":"1.0.4",
"last_update_date":"2024-04-19 09:12:53 +0000",
"last_update_date":"2024-05-30 19:50:57 +0000",
"nicenames":{"family":{"gmail":"Gmail","outlook":"Outlook","yahoo":"Yahoo! Mail","apple-mail":"Apple Mail","aol":"AOL","thunderbird":"Mozilla Thunderbird","microsoft":"Microsoft","samsung-email":"Samsung Email","sfr":"SFR","orange":"Orange","protonmail":"ProtonMail","hey":"HEY","mail-ru":"Mail.ru","fastmail":"Fastmail","laposte":"LaPoste.net","t-online-de":"T-online.de","free-fr":"Free.fr","gmx":"GMX","web-de":"WEB.DE","ionos-1and1":"1&1","rainloop":"RainLoop","wp-pl":"WP.pl"},"platform":{"desktop-app":"Desktop","desktop-webmail":"Desktop Webmail","mobile-webmail":"Mobile Webmail","webmail":"Webmail","ios":"iOS","android":"Android","windows":"Windows","macos":"macOS","windows-mail":"Windows Mail","outlook-com":"Outlook.com"},"support":{"supported":"Supported","mitigated":"Partially supported","unsupported":"Not supported","unknown":"Support unknown","mixed":"Mixed support"},"category":{"html":"HTML","css":"CSS","image":"Image formats","others":"Others"}},
"data":[
{
@@ -4366,7 +4366,7 @@
"last_test_date":"2020-02-06",
"test_url":"https://www.caniemail.com/tests/images.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/xm1T5nQ1MKtHpVSJidhagmt3Z53CjqbkMhorlvuM0Gz57/list",
"stats":{"apple-mail":{"macos":{"13":"y"},"ios":{"13":"y"}},"gmail":{"desktop-webmail":{"2020-02":"n"},"ios":{"2020-02":"n"},"android":{"2020-02":"n"},"mobile-webmail":{"2020-02":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"a #1"},"windows-mail":{"2020-02":"n"},"macos":{"2016":"y","16.80":"y"},"outlook-com":{"2020-02":"y","2024-01":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"samsung-email":{"android":{"9.0":"y"}},"thunderbird":{"windows":{"2020-02":"y"},"macos":{"68.4":"y"}},"aol":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"yahoo":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-02":"n #2","2021-03":"y","2024-04":"y"},"ios":{"2020-02":"n","2024-04":"y"},"android":{"2020-02":"n","2024-04":"y"}},"sfr":{"desktop-webmail":{"2020-02":"n #2"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"n #2"}},"gmx":{"desktop-webmail":{"2022-09":"a #3"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"a #3"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
"stats":{"apple-mail":{"macos":{"13":"y"},"ios":{"13":"y"}},"gmail":{"desktop-webmail":{"2020-02":"n","2024-05":"n"},"ios":{"2020-02":"n"},"android":{"2020-02":"n"},"mobile-webmail":{"2020-02":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"a #1"},"windows-mail":{"2020-02":"n"},"macos":{"2016":"y","16.80":"y"},"outlook-com":{"2020-02":"y","2024-01":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"samsung-email":{"android":{"9.0":"y"}},"thunderbird":{"windows":{"2020-02":"y"},"macos":{"68.4":"y"}},"aol":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"yahoo":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-02":"n #2","2021-03":"y","2024-04":"y"},"ios":{"2020-02":"n","2024-04":"y"},"android":{"2020-02":"n","2024-04":"y"}},"sfr":{"desktop-webmail":{"2020-02":"n #2"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"n #2"}},"gmx":{"desktop-webmail":{"2022-09":"a #3"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"a #3"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
"notes":null,
"notes_by_num":{"1":"Partial: Does not render Base 64 gif format.","2":"Not supported. The `src` is turned into a `nosrc` attribute.","3":"Partial: Only supports Base 64 png format."}
},

View File

@@ -0,0 +1,448 @@
// Package pop3client is borrowed directly from https://github.com/knadh/go-pop3 to reduce dependencies.
// This is used solely for testing the POP3 server
package pop3client
import (
"bufio"
"bytes"
"crypto/tls"
"errors"
"fmt"
"net"
"net/mail"
"strconv"
"strings"
"time"
)
// Client implements a Client e-mail client.
type Client struct {
opt Opt
dialer Dialer
}
// Conn is a stateful connection with the POP3 server/
type Conn struct {
conn net.Conn
r *bufio.Reader
w *bufio.Writer
}
// Opt represents the client configuration.
type Opt struct {
Host string `json:"host"`
Port int `json:"port"`
// Default is 3 seconds.
DialTimeout time.Duration `json:"dial_timeout"`
Dialer Dialer `json:"-"`
TLSEnabled bool `json:"tls_enabled"`
TLSSkipVerify bool `json:"tls_skip_verify"`
}
// Dialer interface
type Dialer interface {
Dial(network, address string) (net.Conn, error)
}
// MessageID contains the ID and size of an individual message.
type MessageID struct {
// ID is the numerical index (non-unique) of the message.
ID int
Size int
// UID is only present if the response is to the UIDL command.
UID string
}
var (
lineBreak = []byte("\r\n")
respOK = []byte("+OK") // `+OK` without additional info
respOKInfo = []byte("+OK ") // `+OK <info>`
respErr = []byte("-ERR") // `-ERR` without additional info
respErrInfo = []byte("-ERR ") // `-ERR <info>`
)
// New returns a new client object using an existing connection.
func New(opt Opt) *Client {
if opt.DialTimeout < time.Millisecond {
opt.DialTimeout = time.Second * 3
}
c := &Client{
opt: opt,
dialer: opt.Dialer,
}
if c.dialer == nil {
c.dialer = &net.Dialer{Timeout: opt.DialTimeout}
}
return c
}
// NewConn creates and returns live POP3 server connection.
func (c *Client) NewConn() (*Conn, error) {
var (
addr = fmt.Sprintf("%s:%d", c.opt.Host, c.opt.Port)
)
conn, err := c.dialer.Dial("tcp", addr)
if err != nil {
return nil, err
}
// No TLS.
if c.opt.TLSEnabled {
// Skip TLS host verification.
tlsCfg := tls.Config{} // #nosec
if c.opt.TLSSkipVerify {
tlsCfg.InsecureSkipVerify = c.opt.TLSSkipVerify // #nosec
} else {
tlsCfg.ServerName = c.opt.Host
}
conn = tls.Client(conn, &tlsCfg)
}
pCon := &Conn{
conn: conn,
r: bufio.NewReader(conn),
w: bufio.NewWriter(conn),
}
// Verify the connection by reading the welcome +OK greeting.
if _, err := pCon.ReadOne(); err != nil {
return nil, err
}
return pCon, nil
}
// Send sends a POP3 command to the server. The given comand is suffixed with "\r\n".
func (c *Conn) Send(b string) error {
if _, err := c.w.WriteString(b + "\r\n"); err != nil {
return err
}
return c.w.Flush()
}
// Cmd sends a command to the server. POP3 responses are either single line or multi-line.
// The first line always with -ERR in case of an error or +OK in case of a successful operation.
// OK+ is always followed by a response on the same line which is either the actual response data
// in case of single line responses, or a help message followed by multiple lines of actual response
// data in case of multiline responses.
// See https://www.shellhacks.com/retrieve-email-pop3-server-command-line/ for examples.
func (c *Conn) Cmd(cmd string, isMulti bool, args ...interface{}) (*bytes.Buffer, error) {
var cmdLine string
// Repeat a %v to format each arg.
if len(args) > 0 {
format := " " + strings.TrimRight(strings.Repeat("%v ", len(args)), " ")
// CMD arg1 argn ...\r\n
cmdLine = fmt.Sprintf(cmd+format, args...)
} else {
cmdLine = cmd
}
if err := c.Send(cmdLine); err != nil {
return nil, err
}
// Read the first line of response to get the +OK/-ERR status.
b, err := c.ReadOne()
if err != nil {
return nil, err
}
// Single line response.
if !isMulti {
return bytes.NewBuffer(b), err
}
buf, err := c.ReadAll()
return buf, err
}
// ReadOne reads a single line response from the conn.
func (c *Conn) ReadOne() ([]byte, error) {
b, _, err := c.r.ReadLine()
if err != nil {
return nil, err
}
r, err := parseResp(b)
return r, err
}
// ReadAll reads all lines from the connection until the POP3 multiline terminator "." is encountered
// and returns a bytes.Buffer of all the read lines.
func (c *Conn) ReadAll() (*bytes.Buffer, error) {
buf := &bytes.Buffer{}
for {
b, _, err := c.r.ReadLine()
if err != nil {
return nil, err
}
// "." indicates the end of a multi-line response.
if bytes.Equal(b, []byte(".")) {
break
}
if _, err := buf.Write(b); err != nil {
return nil, err
}
if _, err := buf.Write(lineBreak); err != nil {
return nil, err
}
}
return buf, nil
}
// Auth authenticates the given credentials with the server.
func (c *Conn) Auth(user, password string) error {
if err := c.User(user); err != nil {
return err
}
if err := c.Pass(password); err != nil {
return err
}
// Issue a NOOP to force the server to respond to the auth.
// Courtesy: github.com/TheCreeper/go-pop3
return c.Noop()
}
// User sends the username to the server.
func (c *Conn) User(s string) error {
_, err := c.Cmd("USER", false, s)
return err
}
// Pass sends the password to the server.
func (c *Conn) Pass(s string) error {
_, err := c.Cmd("PASS", false, s)
return err
}
// Stat returns the number of messages and their total size in bytes in the inbox.
func (c *Conn) Stat() (int, int, error) {
b, err := c.Cmd("STAT", false)
if err != nil {
return 0, 0, err
}
// count size
f := bytes.Fields(b.Bytes())
// Total number of messages.
count, err := strconv.Atoi(string(f[0]))
if err != nil {
return 0, 0, err
}
if count == 0 {
return 0, 0, nil
}
// Total size of all messages in bytes.
size, err := strconv.Atoi(string(f[1]))
if err != nil {
return 0, 0, err
}
return count, size, nil
}
// List returns a list of (message ID, message Size) pairs.
// If the optional msgID > 0, then only that particular message is listed.
// The message IDs are sequential, 1 to N.
func (c *Conn) List(msgID int) ([]MessageID, error) {
var (
buf *bytes.Buffer
err error
)
if msgID <= 0 {
// Multiline response listing all messages.
buf, err = c.Cmd("LIST", true)
} else {
// Single line response listing one message.
buf, err = c.Cmd("LIST", false, msgID)
}
if err != nil {
return nil, err
}
var (
out []MessageID
lines = bytes.Split(buf.Bytes(), lineBreak)
)
for _, l := range lines {
// id size
f := bytes.Fields(l)
if len(f) == 0 {
break
}
id, err := strconv.Atoi(string(f[0]))
if err != nil {
return nil, err
}
size, err := strconv.Atoi(string(f[1]))
if err != nil {
return nil, err
}
out = append(out, MessageID{ID: id, Size: size})
}
return out, nil
}
// Uidl returns a list of (message ID, message UID) pairs. If the optional msgID
// is > 0, then only that particular message is listed. It works like Top() but only works on
// servers that support the UIDL command. Messages size field is not available in the UIDL response.
func (c *Conn) Uidl(msgID int) ([]MessageID, error) {
var (
buf *bytes.Buffer
err error
)
if msgID <= 0 {
// Multiline response listing all messages.
buf, err = c.Cmd("UIDL", true)
} else {
// Single line response listing one message.
buf, err = c.Cmd("UIDL", false, msgID)
}
if err != nil {
return nil, err
}
var (
out []MessageID
lines = bytes.Split(buf.Bytes(), lineBreak)
)
for _, l := range lines {
// id size
f := bytes.Fields(l)
if len(f) == 0 {
break
}
id, err := strconv.Atoi(string(f[0]))
if err != nil {
return nil, err
}
out = append(out, MessageID{ID: id, UID: string(f[1])})
}
return out, nil
}
// Retr downloads a message by the given msgID, parses it and returns it as a *mail.Message.
func (c *Conn) Retr(msgID int) (*mail.Message, error) {
b, err := c.Cmd("RETR", true, msgID)
if err != nil {
return nil, err
}
m, err := mail.ReadMessage(b)
if err != nil {
return nil, err
}
return m, nil
}
// RetrRaw downloads a message by the given msgID and returns the raw []byte
// of the entire message.
func (c *Conn) RetrRaw(msgID int) (*bytes.Buffer, error) {
b, err := c.Cmd("RETR", true, msgID)
return b, err
}
// Top retrieves a message by its ID with full headers and numLines lines of the body.
func (c *Conn) Top(msgID int, numLines int) (*mail.Message, error) {
b, err := c.Cmd("TOP", true, msgID, numLines)
if err != nil {
return nil, err
}
m, err := mail.ReadMessage(b)
if err != nil {
return nil, err
}
return m, nil
}
// Dele deletes one or more messages. The server only executes the
// deletions after a successful Quit().
func (c *Conn) Dele(msgID ...int) error {
for _, id := range msgID {
_, err := c.Cmd("DELE", false, id)
if err != nil {
return err
}
}
return nil
}
// Rset clears the messages marked for deletion in the current session.
func (c *Conn) Rset() error {
_, err := c.Cmd("RSET", false)
return err
}
// Noop issues a do-nothing NOOP command to the server. This is useful for
// prolonging open connections.
func (c *Conn) Noop() error {
_, err := c.Cmd("NOOP", false)
return err
}
// Quit sends the QUIT command to server and gracefully closes the connection.
// Message deletions (DELE command) are only executed by the server on a graceful
// quit and close.
func (c *Conn) Quit() error {
defer c.conn.Close()
if _, err := c.Cmd("QUIT", false); err != nil {
return err
}
return nil
}
// parseResp checks if the response is an error that starts with `-ERR`
// and returns an error with the message that succeeds the error indicator.
// For success `+OK` messages, it returns the remaining response bytes.
func parseResp(b []byte) ([]byte, error) {
if len(b) == 0 {
return nil, nil
}
if bytes.Equal(b, respOK) {
return nil, nil
} else if bytes.HasPrefix(b, respOKInfo) {
return bytes.TrimPrefix(b, respOKInfo), nil
} else if bytes.Equal(b, respErr) {
return nil, errors.New("unknown error (no info specified in response)")
} else if bytes.HasPrefix(b, respErrInfo) {
return nil, errors.New(string(bytes.TrimPrefix(b, respErrInfo)))
}
return nil, fmt.Errorf("unknown response: %s. Neither -ERR, nor +OK", string(b))
}

View File

@@ -112,23 +112,29 @@ func Store(body *[]byte) (string, error) {
return "", err
}
// extract tags from body matches
rawTags := findTagsInRawMessage(body)
// extract plus addresses tags from enmime.Envelope
plusTags := obj.tagsFromPlusAddresses()
// extract tags from X-Tags header
xTags := tools.SetTagCasing(strings.Split(strings.TrimSpace(env.Root.Header.Get("X-Tags")), ","))
// extract tags from search matches
searchTags := tagFilterMatches(id)
// extract tags using pre-set tag filters, empty slice if not set
tags := findTagsInRawMessage(body)
// combine all tags into one slice
tags := append(rawTags, plusTags...)
tags = append(tags, xTags...)
// sort and extract only unique tags
tags = sortedUniqueTags(append(tags, searchTags...))
if !config.TagsDisableXTags {
xTagsHdr := env.Root.Header.Get("X-Tags")
if xTagsHdr != "" {
// extract tags from X-Tags header
tags = append(tags, tools.SetTagCasing(strings.Split(strings.TrimSpace(xTagsHdr), ","))...)
}
}
if !config.TagsDisablePlus {
// get tags from plus-addresses
tags = append(tags, obj.tagsFromPlusAddresses()...)
}
// extract tags from search matches, and sort and extract unique tags
tags = sortedUniqueTags(append(tags, tagFilterMatches(id)...))
setTags := []string{}
if len(tags) > 0 {
if err := SetMessageTags(id, tags); err != nil {
setTags, err = SetMessageTags(id, tags)
if err != nil {
return "", err
}
}
@@ -144,7 +150,7 @@ func Store(body *[]byte) (string, error) {
c.Attachments = attachments
c.Subject = subject
c.Size = size
c.Tags = tags
c.Tags = setTags
c.Snippet = snippet
websockets.Broadcast("new", c)
@@ -593,7 +599,9 @@ func DeleteMessages(ids []string) error {
}
}
err = tx.Commit()
if err := tx.Commit(); err != nil {
return err
}
dbLastAction = time.Now()
addDeletedSize(int64(totalSize))

View File

@@ -137,7 +137,9 @@ func dbApplySchemas() error {
buf := new(bytes.Buffer)
err = t1.Execute(buf, nil)
if err := t1.Execute(buf, nil); err != nil {
return err
}
if _, err := db.Exec(buf.String()); err != nil {
return err
@@ -197,7 +199,7 @@ func migrateTagsToManyMany() {
if len(toConvert) > 0 {
logger.Log().Infof("[migration] converting %d message tags", len(toConvert))
for id, tags := range toConvert {
if err := SetMessageTags(id, tags); err != nil {
if _, err := SetMessageTags(id, tags); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
} else {
if _, err := sqlf.Update(tenant("mailbox")).

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"database/sql"
"fmt"
"regexp"
"sort"
"strings"
@@ -21,7 +22,7 @@ var (
)
// SetMessageTags will set the tags for a given database ID, removing any not in the array
func SetMessageTags(id string, tags []string) error {
func SetMessageTags(id string, tags []string) ([]string, error) {
applyTags := []string{}
for _, t := range tags {
t = tools.CleanTag(t)
@@ -30,6 +31,7 @@ func SetMessageTags(id string, tags []string) error {
}
}
tagNames := []string{}
currentTags := getMessageTags(id)
origTagCount := len(currentTags)
@@ -38,9 +40,12 @@ func SetMessageTags(id string, tags []string) error {
continue
}
if err := AddMessageTag(id, t); err != nil {
return err
name, err := AddMessageTag(id, t)
if err != nil {
return []string{}, err
}
tagNames = append(tagNames, name)
}
if origTagCount > 0 {
@@ -49,42 +54,44 @@ func SetMessageTags(id string, tags []string) error {
for _, t := range currentTags {
if !tools.InArray(t, applyTags) {
if err := DeleteMessageTag(id, t); err != nil {
return err
return []string{}, err
}
}
}
}
return nil
return tagNames, nil
}
// AddMessageTag adds a tag to a message
func AddMessageTag(id, name string) error {
func AddMessageTag(id, name string) (string, error) {
// prevent two identical tags being added at the same time
addTagMutex.Lock()
var tagID int
var foundName sql.NullString
q := sqlf.From(tenant("tags")).
Select("ID").To(&tagID).
Select("Name").To(&foundName).
Where("Name = ?", name)
// if tag exists - add tag to message
if err := q.QueryRowAndClose(context.TODO(), db); err == nil {
addTagMutex.Unlock()
// check message does not already have this tag
var count int
var exists int
if err := sqlf.From(tenant("message_tags")).
Select("COUNT(ID)").To(&count).
Select("COUNT(ID)").To(&exists).
Where("ID = ?", id).
Where("TagID = ?", tagID).
QueryRowAndClose(context.Background(), db); err != nil {
return err
return "", err
}
if count > 0 {
if exists > 0 {
// already exists
return nil
return foundName.String, nil
}
logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id)
@@ -93,7 +100,7 @@ func AddMessageTag(id, name string) error {
Set("ID", id).
Set("TagID", tagID).
ExecAndClose(context.TODO(), db)
return err
return foundName.String, err
}
// new tag, add to the database
@@ -101,7 +108,7 @@ func AddMessageTag(id, name string) error {
Set("Name", name).
ExecAndClose(context.TODO(), db); err != nil {
addTagMutex.Unlock()
return err
return name, err
}
addTagMutex.Unlock()
@@ -174,6 +181,79 @@ func GetAllTagsCount() map[string]int64 {
return tags
}
// RenameTag renames a tag
func RenameTag(from, to string) error {
to = tools.CleanTag(to)
if to == "" || !config.ValidTagRegexp.MatchString(to) {
return fmt.Errorf("invalid tag name: %s", to)
}
if from == to {
return nil // ignore
}
var id, existsID int
q := sqlf.From(tenant("tags")).
Select(`ID`).To(&id).
Where(`Name = ?`, from).
Limit(1)
err := q.QueryRowAndClose(context.Background(), db)
if err != nil {
return fmt.Errorf("tag not found: %s", from)
}
// check if another tag by this name already exists
q = sqlf.From(tenant("tags")).
Select("ID").To(&existsID).
Where(`Name = ?`, to).
Where(`ID != ?`, id).
Limit(1)
err = q.QueryRowAndClose(context.Background(), db)
if err == nil || existsID != 0 {
return fmt.Errorf("tag already exists: %s", to)
}
q = sqlf.Update(tenant("tags")).
Set("Name", to).
Where("ID = ?", id)
_, err = q.ExecAndClose(context.Background(), db)
return err
}
// DeleteTag deleted a tag and removed all references to the tag
func DeleteTag(tag string) error {
var id int
q := sqlf.From(tenant("tags")).
Select(`ID`).To(&id).
Where(`Name = ?`, tag).
Limit(1)
err := q.QueryRowAndClose(context.Background(), db)
if err != nil {
return fmt.Errorf("tag not found: %s", tag)
}
// delete all references
q = sqlf.DeleteFrom(tenant("message_tags")).
Where(`TagID = ?`, id)
_, err = q.ExecAndClose(context.Background(), db)
if err != nil {
return fmt.Errorf("error deleting tag references: %s", err.Error())
}
// delete tag
q = sqlf.DeleteFrom(tenant("tags")).
Where(`ID = ?`, id)
_, err = q.ExecAndClose(context.Background(), db)
if err != nil {
return fmt.Errorf("error deleting tag: %s", err.Error())
}
return nil
}
// PruneUnusedTags will delete all unused tags from the database
func pruneUnusedTags() error {
q := sqlf.From(tenant("tags")).

View File

@@ -24,7 +24,7 @@ func TestTags(t *testing.T) {
}
for i := 0; i < 10; i++ {
if err := SetMessageTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil {
if _, err := SetMessageTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil {
t.Log("error ", err)
t.Fail()
}
@@ -58,7 +58,7 @@ func TestTags(t *testing.T) {
// pad number with 0 to ensure they are returned alphabetically
newTags = append(newTags, fmt.Sprintf("AnotherTag %02d", i))
}
if err := SetMessageTags(id, newTags); err != nil {
if _, err := SetMessageTags(id, newTags); err != nil {
t.Log("error ", err)
t.Fail()
}
@@ -82,7 +82,7 @@ func TestTags(t *testing.T) {
assertEqual(t, "", strings.Join(returnedTags, "|"), "Message tags should be empty")
// apply the same tag twice
if err := SetMessageTags(id, []string{"Duplicate Tag", "Duplicate Tag"}); err != nil {
if _, err := SetMessageTags(id, []string{"Duplicate Tag", "Duplicate Tag"}); err != nil {
t.Log("error ", err)
t.Fail()
}
@@ -94,7 +94,7 @@ func TestTags(t *testing.T) {
}
// apply tag with invalid characters
if err := SetMessageTags(id, []string{"Dirty! \"Tag\""}); err != nil {
if _, err := SetMessageTags(id, []string{"Dirty! \"Tag\""}); err != nil {
t.Log("error ", err)
t.Fail()
}

View File

@@ -2,6 +2,7 @@ package tools
import (
"fmt"
"regexp"
"strings"
)
@@ -24,3 +25,14 @@ func InArray(k string, arr []string) bool {
return false
}
// Normalize will remove any extra spaces, remove newlines, and trim leading and trailing spaces
func Normalize(s string) string {
nlRe := regexp.MustCompile(`\r?\r`)
re := regexp.MustCompile(`\s+`)
s = nlRe.ReplaceAllString(s, " ")
s = re.ReplaceAllString(s, " ")
return strings.TrimSpace(s)
}

View File

@@ -35,14 +35,14 @@ func Unzip(src string, dest string) ([]string, error) {
if f.FileInfo().IsDir() {
// Make Folder
if err := os.MkdirAll(fpath, os.ModePerm); err != nil {
if err := os.MkdirAll(fpath, os.ModePerm); /* #nosec */ err != nil {
return filenames, err
}
continue
}
// Make File
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); /* #nosec */ err != nil {
return filenames, err
}

View File

@@ -329,7 +329,7 @@ func getTempDir() string {
// MkDirIfNotExists will create a directory if it doesn't exist
func mkDirIfNotExists(path string) error {
if !isDir(path) {
return os.MkdirAll(path, os.ModePerm)
return os.MkdirAll(path, os.ModePerm) // #nosec
}
return nil

1029
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,7 @@
"@types/bootstrap": "^5.2.7",
"@types/tinycon": "^0.6.3",
"@vue/compiler-sfc": "^3.2.37",
"esbuild": "^0.21.3",
"esbuild": "^0.23.0",
"esbuild-plugin-vue-next": "^0.1.4",
"esbuild-sass-plugin": "^3.0.0"
}

View File

@@ -9,18 +9,13 @@ import (
"net/mail"
"strconv"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/htmlcheck"
"github.com/axllent/mailpit/internal/linkcheck"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/spamassassin"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/smtpd"
"github.com/gorilla/mux"
"github.com/lithammer/shortuuid/v4"
)
// GetMessages returns a paginated list of messages as JSON
@@ -523,206 +518,6 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
}
// GetAllTags (method: GET) will get all tags currently in use
func GetAllTags(w http.ResponseWriter, _ *http.Request) {
// swagger:route GET /api/v1/tags tags GetAllTags
//
// # Get all current tags
//
// Returns a JSON array of all unique message tags.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: ArrayResponse
// default: ErrorResponse
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(storage.GetAllTags()); err != nil {
httpError(w, err.Error())
}
}
// SetMessageTags (method: PUT) will set the tags for all provided IDs
func SetMessageTags(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/tags tags SetTags
//
// # Set message tags
//
// This will overwrite any existing tags for selected message database IDs. To remove all tags from a message, pass an empty tags array.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
decoder := json.NewDecoder(r.Body)
var data struct {
Tags []string
IDs []string
}
err := decoder.Decode(&data)
if err != nil {
httpError(w, err.Error())
return
}
ids := data.IDs
if len(ids) > 0 {
for _, id := range ids {
if err := storage.SetMessageTags(id, data.Tags); err != nil {
httpError(w, err.Error())
return
}
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server.
func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
// swagger:route POST /api/v1/message/{ID}/release message ReleaseMessage
//
// # Release message
//
// Release a message via a pre-configured external SMTP server. This is only enabled if message relaying has been configured.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
msg, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
decoder := json.NewDecoder(r.Body)
data := releaseMessageRequestBody{}
if err := decoder.Decode(&data); err != nil {
httpError(w, err.Error())
return
}
for _, to := range data.To {
address, err := mail.ParseAddress(to)
if err != nil {
httpError(w, "Invalid email address: "+to)
return
}
if config.SMTPRelayConfig.AllowedRecipientsRegexp != nil && !config.SMTPRelayConfig.AllowedRecipientsRegexp.MatchString(address.Address) {
httpError(w, "Mail address does not match allowlist: "+to)
return
}
}
if len(data.To) == 0 {
httpError(w, "No valid addresses found")
return
}
reader := bytes.NewReader(msg)
m, err := mail.ReadMessage(reader)
if err != nil {
httpError(w, err.Error())
return
}
fromAddresses, err := m.Header.AddressList("From")
if err != nil {
httpError(w, err.Error())
return
}
if len(fromAddresses) == 0 {
httpError(w, "No From header found")
return
}
from := fromAddresses[0].Address
// if sender is used, then change from to the sender
if senders, err := m.Header.AddressList("Sender"); err == nil {
from = senders[0].Address
}
msg, err = tools.RemoveMessageHeaders(msg, []string{"Bcc"})
if err != nil {
httpError(w, err.Error())
return
}
// set the Return-Path and SMTP mfrom
if config.SMTPRelayConfig.ReturnPath != "" {
if m.Header.Get("Return-Path") != "<"+config.SMTPRelayConfig.ReturnPath+">" {
msg, err = tools.RemoveMessageHeaders(msg, []string{"Return-Path"})
if err != nil {
httpError(w, err.Error())
return
}
msg = append([]byte("Return-Path: <"+config.SMTPRelayConfig.ReturnPath+">\r\n"), msg...)
}
from = config.SMTPRelayConfig.ReturnPath
}
// update message date
msg, err = tools.UpdateMessageHeader(msg, "Date", time.Now().Format(time.RFC1123Z))
if err != nil {
httpError(w, err.Error())
return
}
// generate unique ID
uid := shortuuid.New() + "@mailpit"
// update Message-Id with unique ID
msg, err = tools.UpdateMessageHeader(msg, "Message-Id", "<"+uid+">")
if err != nil {
httpError(w, err.Error())
return
}
if err := smtpd.Send(from, data.To, msg); err != nil {
logger.Log().Errorf("[smtp] error sending message: %s", err.Error())
httpError(w, "SMTP error: "+err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// HTMLCheck returns a summary of the HTML client support
func HTMLCheck(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/html-check Other HTMLCheck

167
server/apiv1/release.go Normal file
View File

@@ -0,0 +1,167 @@
package apiv1
import (
"bytes"
"encoding/json"
"net/http"
"net/mail"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/smtpd"
"github.com/gorilla/mux"
"github.com/lithammer/shortuuid/v4"
)
// ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server.
func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
// swagger:route POST /api/v1/message/{ID}/release message ReleaseMessage
//
// # Release message
//
// Release a message via a pre-configured external SMTP server. This is only enabled if message relaying has been configured.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
msg, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
decoder := json.NewDecoder(r.Body)
data := releaseMessageRequestBody{}
if err := decoder.Decode(&data); err != nil {
httpError(w, err.Error())
return
}
blocked := []string{}
notAllowed := []string{}
for _, to := range data.To {
address, err := mail.ParseAddress(to)
if err != nil {
httpError(w, "Invalid email address: "+to)
return
}
if config.SMTPRelayConfig.AllowedRecipientsRegexp != nil && !config.SMTPRelayConfig.AllowedRecipientsRegexp.MatchString(address.Address) {
notAllowed = append(notAllowed, to)
continue
}
if config.SMTPRelayConfig.BlockedRecipientsRegexp != nil && config.SMTPRelayConfig.BlockedRecipientsRegexp.MatchString(address.Address) {
blocked = append(blocked, to)
continue
}
}
if len(notAllowed) > 0 {
addr := tools.Plural(len(notAllowed), "Address", "Addresses")
httpError(w, "Failed: "+addr+" do not match the allowlist: "+strings.Join(notAllowed, ", "))
return
}
if len(blocked) > 0 {
addr := tools.Plural(len(blocked), "Address", "Addresses")
httpError(w, "Failed: "+addr+" found on blocklist: "+strings.Join(blocked, ", "))
return
}
if len(data.To) == 0 {
httpError(w, "No valid addresses found")
return
}
reader := bytes.NewReader(msg)
m, err := mail.ReadMessage(reader)
if err != nil {
httpError(w, err.Error())
return
}
fromAddresses, err := m.Header.AddressList("From")
if err != nil {
httpError(w, err.Error())
return
}
if len(fromAddresses) == 0 {
httpError(w, "No From header found")
return
}
from := fromAddresses[0].Address
// if sender is used, then change from to the sender
if senders, err := m.Header.AddressList("Sender"); err == nil {
from = senders[0].Address
}
msg, err = tools.RemoveMessageHeaders(msg, []string{"Bcc"})
if err != nil {
httpError(w, err.Error())
return
}
// set the Return-Path and SMTP from
if config.SMTPRelayConfig.ReturnPath != "" {
if m.Header.Get("Return-Path") != "<"+config.SMTPRelayConfig.ReturnPath+">" {
msg, err = tools.RemoveMessageHeaders(msg, []string{"Return-Path"})
if err != nil {
httpError(w, err.Error())
return
}
msg = append([]byte("Return-Path: <"+config.SMTPRelayConfig.ReturnPath+">\r\n"), msg...)
}
from = config.SMTPRelayConfig.ReturnPath
}
// update message date
msg, err = tools.UpdateMessageHeader(msg, "Date", time.Now().Format(time.RFC1123Z))
if err != nil {
httpError(w, err.Error())
return
}
// generate unique ID
uid := shortuuid.New() + "@mailpit"
// update Message-Id with unique ID
msg, err = tools.UpdateMessageHeader(msg, "Message-Id", "<"+uid+">")
if err != nil {
httpError(w, err.Error())
return
}
if err := smtpd.Send(from, data.To, msg); err != nil {
logger.Log().Errorf("[smtp] error sending message: %s", err.Error())
httpError(w, "SMTP error: "+err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}

View File

@@ -95,6 +95,22 @@ type setTagsRequestBody struct {
IDs []string
}
// swagger:parameters RenameTag
type renameTagParams struct {
// in: body
Body *renameTagRequestBody
}
// Rename tag request
// swagger:model renameTagRequestBody
type renameTagRequestBody struct {
// New name
//
// required: true
// example: New name
Name string
}
// swagger:parameters ReleaseMessage
type releaseMessageParams struct {
// Message database ID

171
server/apiv1/tags.go Normal file
View File

@@ -0,0 +1,171 @@
package apiv1
import (
"encoding/json"
"net/http"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/server/websockets"
"github.com/gorilla/mux"
)
// GetAllTags (method: GET) will get all tags currently in use
func GetAllTags(w http.ResponseWriter, _ *http.Request) {
// swagger:route GET /api/v1/tags tags GetAllTags
//
// # Get all current tags
//
// Returns a JSON array of all unique message tags.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: ArrayResponse
// default: ErrorResponse
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(storage.GetAllTags()); err != nil {
httpError(w, err.Error())
}
}
// SetMessageTags (method: PUT) will set the tags for all provided IDs
func SetMessageTags(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/tags tags SetTags
//
// # Set message tags
//
// This will overwrite any existing tags for selected message database IDs. To remove all tags from a message, pass an empty tags array.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
decoder := json.NewDecoder(r.Body)
var data struct {
Tags []string
IDs []string
}
err := decoder.Decode(&data)
if err != nil {
httpError(w, err.Error())
return
}
ids := data.IDs
if len(ids) > 0 {
for _, id := range ids {
if _, err := storage.SetMessageTags(id, data.Tags); err != nil {
httpError(w, err.Error())
return
}
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// RenameTag (method: PUT) used to rename a tag
func RenameTag(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/tags/{tag} tags RenameTag
//
// # Rename a tag
//
// Renames a tag.
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: tag
// in: path
// description: The url-encoded tag name to rename
// required: true
// type: string
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
vars := mux.Vars(r)
tag := vars["tag"]
decoder := json.NewDecoder(r.Body)
var data struct {
Name string
}
err := decoder.Decode(&data)
if err != nil {
httpError(w, err.Error())
return
}
if err := storage.RenameTag(tag, data.Name); err != nil {
httpError(w, err.Error())
return
}
websockets.Broadcast("prune", nil)
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// DeleteTag (method: DELETE) used to delete a tag
func DeleteTag(w http.ResponseWriter, r *http.Request) {
// swagger:route DELETE /api/v1/tags/{tag} tags DeleteTag
//
// # Delete a tag
//
// Deletes a tag. This will not delete any messages with this tag.
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: tag
// in: path
// description: The url-encoded tag name to delete
// required: true
// type: string
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
vars := mux.Vars(r)
tag := vars["tag"]
if err := storage.DeleteTag(tag); err != nil {
httpError(w, err.Error())
return
}
websockets.Broadcast("prune", nil)
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}

View File

@@ -12,6 +12,8 @@ import (
//
// swagger:model WebUIConfiguration
type webUIConfiguration struct {
// Optional label to identify this Mailpit instance
Label string
// Message Relay information
MessageRelay struct {
// Whether message relaying (release) is enabled
@@ -22,6 +24,8 @@ type webUIConfiguration struct {
ReturnPath string
// Only allow relaying to these recipients (regex)
AllowedRecipients string
// Block relaying to these recipients (regex)
BlockedRecipients string
// DEPRECATED 2024/03/12
// swagger:ignore
RecipientAllowlist string
@@ -53,11 +57,13 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
// default: ErrorResponse
conf := webUIConfiguration{}
conf.Label = config.Label
conf.MessageRelay.Enabled = config.ReleaseEnabled
if config.ReleaseEnabled {
conf.MessageRelay.SMTPServer = fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
conf.MessageRelay.ReturnPath = config.SMTPRelayConfig.ReturnPath
conf.MessageRelay.AllowedRecipients = config.SMTPRelayConfig.AllowedRecipients
conf.MessageRelay.BlockedRecipients = config.SMTPRelayConfig.BlockedRecipients
// DEPRECATED 2024/03/12
conf.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.AllowedRecipients
}

View File

@@ -72,5 +72,5 @@ func getSafeArg(args []string, nr int) (string, error) {
if nr < len(args) {
return args[nr], nil
}
return "", errors.New("Out of range")
return "", errors.New("-ERR out of range")
}

View File

@@ -1,317 +0,0 @@
// Package pop3 is a simple POP3 server for Mailpit.
// By default it is disabled unless password credentials have been loaded.
//
// References: https://github.com/r0stig/golang-pop3 | https://github.com/inbucket/inbucket
// See RFC: https://datatracker.ietf.org/doc/html/rfc1939
package pop3
import (
"bufio"
"crypto/tls"
"fmt"
"net"
"strconv"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/server/websockets"
)
const (
// UNAUTHORIZED state
UNAUTHORIZED = 1
// TRANSACTION state
TRANSACTION = 2
// UPDATE state
UPDATE = 3
)
// Run will start the pop3 server if enabled
func Run() {
if auth.POP3Credentials == nil || config.POP3Listen == "" {
// POP3 server is disabled without authentication
return
}
var listener net.Listener
var err error
if config.POP3TLSCert != "" {
cer, err2 := tls.LoadX509KeyPair(config.POP3TLSCert, config.POP3TLSKey)
if err2 != nil {
logger.Log().Errorf("[pop3] %s", err2.Error())
return
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cer},
MinVersion: tls.VersionTLS12,
}
listener, err = tls.Listen("tcp", config.POP3Listen, tlsConfig)
} else {
// unencrypted
listener, err = net.Listen("tcp", config.POP3Listen)
}
if err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
return
}
logger.Log().Infof("[pop3] starting on %s", config.POP3Listen)
for {
conn, err := listener.Accept()
if err != nil {
continue
}
// run as goroutine
go handleClient(conn)
}
}
type message struct {
ID string
Size float64
}
func handleClient(conn net.Conn) {
var (
user = ""
state = 1
toDelete = []string{}
)
defer func() {
if state == UPDATE {
for _, id := range toDelete {
_ = storage.DeleteMessages([]string{id})
}
if len(toDelete) > 0 {
// update web UI to remove deleted messages
websockets.Broadcast("prune", nil)
}
}
if err := conn.Close(); err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
}
}()
reader := bufio.NewReader(conn)
messages := []message{}
// State
// 1 = Unauthorized
// 2 = Transaction mode
// 3 = update mode
logger.Log().Debugf("[pop3] connection opened by %s", conn.RemoteAddr().String())
// First welcome the new connection
sendResponse(conn, "+OK Mailpit POP3 server")
timeoutDuration := 30 * time.Second
for {
// POP3 server enforced a timeout of 30 seconds
if err := conn.SetDeadline(time.Now().Add(timeoutDuration)); err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
return
}
// Reads a line from the client
rawLine, err := reader.ReadString('\n')
if err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
return
}
// Parses the command
cmd, args := getCommand(rawLine)
logger.Log().Debugf("[pop3] received: %s (%s)", strings.TrimSpace(rawLine), conn.RemoteAddr().String())
if cmd == "CAPA" {
// List our capabilities per RFC2449
sendResponse(conn, "+OK Capability list follows")
sendResponse(conn, "TOP")
sendResponse(conn, "USER")
sendResponse(conn, "UIDL")
sendResponse(conn, "IMPLEMENTATION Mailpit")
sendResponse(conn, ".")
continue
} else if cmd == "USER" && state == UNAUTHORIZED {
if len(args) != 1 {
sendResponse(conn, "-ERR must supply a user")
return
}
// always true - stash for PASS
sendResponse(conn, "+OK")
user = args[0]
} else if cmd == "PASS" && state == UNAUTHORIZED {
if len(args) != 1 {
sendResponse(conn, "-ERR must supply a password")
return
}
pass := args[0]
if authUser(user, pass) {
sendResponse(conn, "+OK signed in")
messages, err = getMessages()
if err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
}
state = 2
} else {
sendResponse(conn, "-ERR invalid password")
logger.Log().Warnf("[pop3] failed login: %s", user)
}
} else if cmd == "STAT" && state == TRANSACTION {
totalSize := float64(0)
for _, m := range messages {
totalSize = totalSize + m.Size
}
sendResponse(conn, fmt.Sprintf("+OK %d %d", len(messages), int64(totalSize)))
} else if cmd == "LIST" && state == TRANSACTION {
totalSize := float64(0)
for _, m := range messages {
totalSize = totalSize + m.Size
}
sendData(conn, fmt.Sprintf("+OK %d messages (%d octets)", len(messages), int64(totalSize)))
// print all sizes
for row, m := range messages {
sendData(conn, fmt.Sprintf("%d %d", row+1, m.Size))
}
// end
sendData(conn, ".")
} else if cmd == "UIDL" && state == TRANSACTION {
totalSize := float64(0)
for _, m := range messages {
totalSize = totalSize + m.Size
}
sendData(conn, "+OK unique-id listing follows")
// print all message IDS
for row, m := range messages {
sendData(conn, fmt.Sprintf("%d %s", row+1, m.ID))
}
// end
sendData(conn, ".")
} else if cmd == "RETR" && state == TRANSACTION {
if len(args) != 1 {
sendResponse(conn, "-ERR no such message")
return
}
nr, err := strconv.Atoi(args[0])
if err != nil {
sendResponse(conn, "-ERR no such message")
return
}
if nr < 1 || nr > len(messages) {
sendResponse(conn, "-ERR no such message")
return
}
m := messages[nr-1]
raw, err := storage.GetMessageRaw(m.ID)
if err != nil {
sendResponse(conn, "-ERR no such message")
return
}
size := len(raw)
sendData(conn, fmt.Sprintf("+OK %d octets", size))
sendData(conn, strings.Replace(string(raw), "\n.", "\n..", -1))
sendData(conn, ".")
} else if cmd == "TOP" && state == TRANSACTION {
arg, err := getSafeArg(args, 0)
if err != nil {
sendResponse(conn, "-ERR TOP requires two arguments")
return
}
nr, err := strconv.Atoi(arg)
if err != nil {
sendResponse(conn, "-ERR TOP requires two arguments")
return
}
if nr < 1 || nr > len(messages) {
sendResponse(conn, "-ERR no such message")
return
}
arg2, err := getSafeArg(args, 1)
if err != nil {
sendResponse(conn, "-ERR TOP requires two arguments")
return
}
lines, err := strconv.Atoi(arg2)
if err != nil {
sendResponse(conn, "-ERR TOP requires two arguments")
return
}
m := messages[nr-1]
headers, body, err := getTop(m.ID, lines)
if err != nil {
sendResponse(conn, err.Error())
return
}
sendData(conn, "+OK Top of message follows")
sendData(conn, headers+"\r\n")
sendData(conn, body)
sendData(conn, ".")
} else if cmd == "NOOP" && state == TRANSACTION {
sendData(conn, "+OK")
} else if cmd == "DELE" && state == TRANSACTION {
arg, _ := getSafeArg(args, 0)
nr, err := strconv.Atoi(arg)
if err != nil {
logger.Log().Warnf("[pop3] -ERR invalid DELETE integer: %s", arg)
sendResponse(conn, "-ERR invalid integer")
return
}
if nr < 1 || nr > len(messages) {
logger.Log().Warnf("[pop3] -ERR no such message")
sendResponse(conn, "-ERR no such message")
return
}
toDelete = append(toDelete, messages[nr-1].ID)
sendResponse(conn, "+OK")
} else if cmd == "RSET" && state == TRANSACTION {
toDelete = []string{}
sendData(conn, "+OK")
} else if cmd == "QUIT" {
state = UPDATE
return
} else {
logger.Log().Warnf("[pop3] -ERR %s not implemented", cmd)
sendResponse(conn, fmt.Sprintf("-ERR %s not implemented", cmd))
}
}
}

365
server/pop3/pop3_test.go Normal file
View File

@@ -0,0 +1,365 @@
package pop3
import (
"bytes"
"fmt"
"math/rand/v2"
"net"
"os"
"strings"
"testing"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/pop3client"
"github.com/axllent/mailpit/internal/storage"
"github.com/jhillyerd/enmime"
)
var (
testingPort int
)
func TestPOP3(t *testing.T) {
t.Log("Testing POP3 server")
setup()
defer storage.Close()
// connect with bad password
t.Log("Testing invalid login")
c, err := connectBadAuth()
if err == nil {
t.Error("invalid login gained access")
return
}
t.Log("Testing valid login")
c, err = connectAuth()
if err != nil {
t.Errorf(err.Error())
return
}
count, size, err := c.Stat()
if err != nil {
t.Errorf(err.Error())
return
}
assertEqual(t, count, 0, "incorrect message count")
assertEqual(t, size, 0, "incorrect size")
// quit else we get old data
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
t.Log("Inserting 50 messages")
insertEmailData(t) // insert 50 messages
c, err = connectAuth()
if err != nil {
t.Errorf(err.Error())
return
}
count, _, err = c.Stat()
if err != nil {
t.Errorf(err.Error())
return
}
assertEqual(t, count, 50, "incorrect message count")
t.Log("Fetching 20 messages")
for i := 1; i <= 20; i++ {
_, err := c.Retr(i)
if err != nil {
t.Errorf(err.Error())
return
}
}
t.Log("Deleting 25 messages")
for i := 1; i <= 25; i++ {
if err := c.Dele(i); err != nil {
t.Errorf(err.Error())
return
}
}
// messages get deleted after a QUIT
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
c, err = connectAuth()
if err != nil {
t.Errorf(err.Error())
return
}
t.Log("Fetching message count")
count, _, err = c.Stat()
if err != nil {
t.Errorf(err.Error())
return
}
assertEqual(t, count, 25, "incorrect message count")
// messages get deleted after a QUIT
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
c, err = connectAuth()
if err != nil {
t.Errorf(err.Error())
return
}
t.Log("Deleting 25 messages")
for i := 1; i <= 25; i++ {
if err := c.Dele(i); err != nil {
t.Errorf(err.Error())
return
}
}
t.Log("Undeleting messages")
if err := c.Rset(); err != nil {
t.Errorf(err.Error())
return
}
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
c, err = connectAuth()
if err != nil {
t.Errorf(err.Error())
return
}
count, _, err = c.Stat()
if err != nil {
t.Errorf(err.Error())
return
}
assertEqual(t, count, 25, "incorrect message count")
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
}
func TestAuthentication(t *testing.T) {
// commands only allowed after authentication
authCommands := make(map[string]bool)
authCommands["STAT"] = false
authCommands["LIST"] = true
authCommands["NOOP"] = false
authCommands["RSET"] = false
authCommands["RETR 1"] = true
t.Log("Testing authenticated commands while not logged in")
setup()
defer storage.Close()
insertEmailData(t) // insert 50 messages
// non-authenticated connection
c, err := connect()
if err != nil {
t.Errorf(err.Error())
return
}
for cmd, multi := range authCommands {
if _, err := c.Cmd(cmd, multi); err == nil {
t.Errorf("%s should require authentication", cmd)
return
}
if _, err := c.Cmd(strings.ToLower(cmd), multi); err == nil {
t.Errorf("%s should require authentication", cmd)
return
}
}
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
t.Log("Testing authenticated commands while logged in")
// authenticated connection
c, err = connectAuth()
if err != nil {
t.Errorf(err.Error())
return
}
for cmd, multi := range authCommands {
if _, err := c.Cmd(cmd, multi); err != nil {
t.Errorf("%s should work when authenticated", cmd)
return
}
if _, err := c.Cmd(strings.ToLower(cmd), multi); err != nil {
t.Errorf("%s should work when authenticated", cmd)
return
}
}
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
}
func setup() {
auth.SetPOP3Auth("username:password")
logger.NoLogging = true
config.MaxMessages = 0
config.Database = os.Getenv("MP_DATABASE")
var foundPort bool
for !foundPort {
testingPort = randRange(1111, 2000)
if portFree(testingPort) {
foundPort = true
}
}
config.POP3Listen = fmt.Sprintf("localhost:%d", testingPort)
if err := storage.InitDB(); err != nil {
panic(err)
}
if err := storage.DeleteAllMessages(); err != nil {
panic(err)
}
go Run()
time.Sleep(time.Second)
}
// connect and authenticate
func connectAuth() (*pop3client.Conn, error) {
c, err := connect()
if err != nil {
return c, err
}
err = c.Auth("username", "password")
return c, err
}
// connect and authenticate
func connectBadAuth() (*pop3client.Conn, error) {
c, err := connect()
if err != nil {
return c, err
}
err = c.Auth("username", "notPassword")
return c, err
}
// connect but do not authenticate
func connect() (*pop3client.Conn, error) {
p := pop3client.New(pop3client.Opt{
Host: "localhost",
Port: testingPort,
TLSEnabled: false,
})
c, err := p.NewConn()
if err != nil {
return c, err
}
return c, err
}
func portFree(port int) bool {
ln, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", port))
if err != nil {
return false
}
if err := ln.Close(); err != nil {
panic(err)
}
return true
}
func randRange(min, max int) int {
return rand.IntN(max-min) + min
}
func insertEmailData(t *testing.T) {
for i := 0; i < 50; 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()
}
bufBytes := buf.Bytes()
id, err := storage.Store(&bufBytes)
if err != nil {
t.Log("error ", err)
t.Fail()
}
if _, err := storage.SetMessageTags(id, []string{fmt.Sprintf("Test tag %03d", i)}); err != nil {
t.Log("error ", err)
t.Fail()
}
}
}
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)
}

319
server/pop3/server.go Normal file
View File

@@ -0,0 +1,319 @@
// Package pop3 is a simple POP3 server for Mailpit.
// By default it is disabled unless password credentials have been loaded.
//
// References: https://github.com/r0stig/golang-pop3 | https://github.com/inbucket/inbucket
// See RFC: https://datatracker.ietf.org/doc/html/rfc1939
package pop3
import (
"bufio"
"crypto/tls"
"fmt"
"io"
"net"
"strconv"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/server/websockets"
)
const (
// AUTHORIZATION is the initial state
AUTHORIZATION = 1
// TRANSACTION is the state after login
TRANSACTION = 2
// UPDATE is the state before closing
UPDATE = 3
)
// Run will start the POP3 server if enabled
func Run() {
if auth.POP3Credentials == nil || config.POP3Listen == "" {
// POP3 server is disabled without authentication
return
}
var listener net.Listener
var err error
if config.POP3TLSCert != "" {
cer, err2 := tls.LoadX509KeyPair(config.POP3TLSCert, config.POP3TLSKey)
if err2 != nil {
logger.Log().Errorf("[pop3] %s", err2.Error())
return
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cer},
MinVersion: tls.VersionTLS12,
}
listener, err = tls.Listen("tcp", config.POP3Listen, tlsConfig)
} else {
// unencrypted
listener, err = net.Listen("tcp", config.POP3Listen)
}
if err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
return
}
logger.Log().Infof("[pop3] starting on %s", config.POP3Listen)
for {
conn, err := listener.Accept()
if err != nil {
logger.Log().Errorf("[pop3] accept error: %s", err.Error())
continue
}
// run as goroutine
go handleClient(conn)
}
}
type message struct {
ID string
Size float64
}
func handleClient(conn net.Conn) {
var (
user = ""
state = AUTHORIZATION // Start with AUTHORIZATION state
toDelete []string // Track messages marked for deletion
messages []message
)
defer func() {
if state == UPDATE {
if len(toDelete) > 0 {
if err := storage.DeleteMessages(toDelete); err != nil {
logger.Log().Errorf("[pop3] error deleting: %s", err.Error())
}
// Update web UI to remove deleted messages
websockets.Broadcast("prune", nil)
}
}
if err := conn.Close(); err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
}
}()
reader := bufio.NewReader(conn)
logger.Log().Debugf("[pop3] connection opened by %s", conn.RemoteAddr().String())
// First welcome the new connection
serverName := "Mailpit"
if config.Label != "" {
serverName = fmt.Sprintf("Mailpit (%s)", config.Label)
}
sendResponse(conn, fmt.Sprintf("+OK %s POP3 server", serverName))
// Set 10 minutes timeout according to RFC1939
timeoutDuration := 600 * time.Second
for {
// Set read deadline
if err := conn.SetReadDeadline(time.Now().Add(timeoutDuration)); err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
return
}
// Reads a line from the client
rawLine, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
logger.Log().Debugf("[pop3] client disconnected: %s", conn.RemoteAddr().String())
} else {
logger.Log().Errorf("[pop3] read error: %s", err.Error())
}
return
}
// Parses the command
cmd, args := getCommand(rawLine)
cmd = strings.ToUpper(cmd) // Commands in the POP3 are case-insensitive
logger.Log().Debugf("[pop3] received: %s (%s)", strings.TrimSpace(rawLine), conn.RemoteAddr().String())
switch cmd {
case "CAPA":
// List our capabilities per RFC2449
sendResponse(conn, "+OK capability list follows")
sendResponse(conn, "TOP")
sendResponse(conn, "USER")
sendResponse(conn, "UIDL")
sendResponse(conn, "IMPLEMENTATION Mailpit")
sendResponse(conn, ".")
case "USER":
if state == AUTHORIZATION {
if len(args) != 1 {
sendResponse(conn, "-ERR must supply a user")
return
}
sendResponse(conn, "+OK")
user = args[0]
} else {
sendResponse(conn, "-ERR user already specified")
}
case "PASS":
if state == AUTHORIZATION {
if user == "" {
sendResponse(conn, "-ERR must supply a user")
return
}
if len(args) != 1 {
sendResponse(conn, "-ERR must supply a password")
return
}
pass := args[0]
if authUser(user, pass) {
sendResponse(conn, "+OK signed in")
var err error
messages, err = getMessages()
if err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
}
state = TRANSACTION
} else {
sendResponse(conn, "-ERR invalid password")
logger.Log().Warnf("[pop3] failed login: %s", user)
}
} else {
sendResponse(conn, "-ERR user not specified")
}
case "STAT", "LIST", "UIDL", "RETR", "TOP", "NOOP", "DELE", "RSET":
if state == TRANSACTION {
handleTransactionCommand(conn, cmd, args, messages, &toDelete)
} else {
sendResponse(conn, "-ERR user not authenticated")
}
case "QUIT":
sendResponse(conn, "+OK goodbye")
state = UPDATE
return
default:
sendResponse(conn, "-ERR unknown command")
}
}
}
func handleTransactionCommand(conn net.Conn, cmd string, args []string, messages []message, toDelete *[]string) {
switch cmd {
case "STAT":
totalSize := float64(0)
for _, m := range messages {
totalSize += m.Size
}
sendResponse(conn, fmt.Sprintf("+OK %d %d", len(messages), int64(totalSize)))
case "LIST":
totalSize := float64(0)
for _, m := range messages {
totalSize += m.Size
}
sendResponse(conn, fmt.Sprintf("+OK %d messages (%d octets)", len(messages), int64(totalSize)))
for row, m := range messages {
sendResponse(conn, fmt.Sprintf("%d %d", row+1, int64(m.Size))) // Convert Size to int64 when printing
}
sendResponse(conn, ".")
case "UIDL":
sendResponse(conn, "+OK unique-id listing follows")
for row, m := range messages {
sendResponse(conn, fmt.Sprintf("%d %s", row+1, m.ID))
}
sendResponse(conn, ".")
case "RETR":
if len(args) != 1 {
sendResponse(conn, "-ERR no such message")
return
}
nr, err := strconv.Atoi(args[0])
if err != nil || nr < 1 || nr > len(messages) {
sendResponse(conn, "-ERR no such message")
return
}
m := messages[nr-1]
raw, err := storage.GetMessageRaw(m.ID)
if err != nil {
sendResponse(conn, "-ERR no such message")
return
}
size := len(raw)
sendResponse(conn, fmt.Sprintf("+OK %d octets", size))
// When all lines of the response have been sent, a
// final line is sent, consisting of a termination octet (decimal code
// 046, ".") and a CRLF pair. If any line of the multi-line response
// begins with the termination octet, the line is "byte-stuffed" by
// pre-pending the termination octet to that line of the response.
// @see: https://www.ietf.org/rfc/rfc1939.txt
sendData(conn, strings.Replace(string(raw), "\n.", "\n..", -1))
sendResponse(conn, ".")
case "TOP":
arg, err := getSafeArg(args, 0)
if err != nil {
sendResponse(conn, "-ERR TOP requires two arguments")
return
}
nr, err := strconv.Atoi(arg)
if err != nil || nr < 1 || nr > len(messages) {
sendResponse(conn, "-ERR no such message")
return
}
arg2, err := getSafeArg(args, 1)
if err != nil {
sendResponse(conn, "-ERR TOP requires two arguments")
return
}
lines, err := strconv.Atoi(arg2)
if err != nil {
sendResponse(conn, "-ERR TOP requires two arguments")
return
}
m := messages[nr-1]
headers, body, err := getTop(m.ID, lines)
if err != nil {
sendResponse(conn, err.Error())
return
}
sendResponse(conn, "+OK top of message follows")
sendData(conn, headers+"\r\n")
sendData(conn, body)
sendResponse(conn, ".")
case "NOOP":
sendResponse(conn, "+OK")
case "DELE":
arg, _ := getSafeArg(args, 0)
nr, err := strconv.Atoi(arg)
if err != nil || nr < 1 || nr > len(messages) {
sendResponse(conn, "-ERR no such message")
return
}
m := messages[nr-1]
*toDelete = append(*toDelete, m.ID)
sendResponse(conn, "+OK message marked for deletion")
case "RSET":
*toDelete = []string{}
sendResponse(conn, "+OK")
default:
sendResponse(conn, "-ERR unknown command")
}
}

View File

@@ -132,6 +132,8 @@ func apiRoutes() *mux.Router {
r.HandleFunc(config.Webroot+"api/v1/send", middleWareFunc(apiv1.SendMessageHandler)).Methods("POST")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.GetAllTags)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetMessageTags)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.RenameTag)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.DeleteTag)).Methods("DELETE")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}", middleWareFunc(apiv1.DownloadAttachment)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/headers", middleWareFunc(apiv1.GetHeaders)).Methods("GET")

View File

@@ -383,7 +383,7 @@ func insertEmailData(t *testing.T) {
t.Fail()
}
if err := storage.SetMessageTags(id, []string{fmt.Sprintf("Test tag %03d", i)}); err != nil {
if _, err := storage.SetMessageTags(id, []string{fmt.Sprintf("Test tag %03d", i)}); err != nil {
t.Log("error ", err)
t.Fail()
}

View File

@@ -8,6 +8,19 @@ import (
)
func autoRelayMessage(from string, to []string, data *[]byte) {
if config.SMTPRelayConfig.BlockedRecipientsRegexp != nil {
filteredTo := []string{}
for _, address := range to {
if config.SMTPRelayConfig.BlockedRecipientsRegexp.MatchString(address) {
logger.Log().Debugf("[smtp] ignoring auto-relay to %s: found in blocklist", address)
continue
}
filteredTo = append(filteredTo, address)
}
to = filteredTo
}
if len(to) == 0 {
return
}

View File

@@ -223,6 +223,10 @@ func listenAndServe(addr string, handler smtpd.MsgIDHandler, authHandler smtpd.A
DisableReverseDNS: DisableReverseDNS,
}
if config.Label != "" {
srv.Appname = fmt.Sprintf("Mailpit (%s)", config.Label)
}
if config.SMTPAuthAllowInsecure {
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
}

View File

@@ -2,6 +2,7 @@
import CommonMixins from './mixins/CommonMixins'
import Favicon from './components/Favicon.vue'
import Notifications from './components/Notifications.vue'
import EditTags from './components/EditTags.vue'
import { RouterView } from 'vue-router'
import { mailbox } from "./stores/mailbox"
@@ -11,14 +12,20 @@ export default {
components: {
Favicon,
Notifications,
EditTags
},
beforeMount() {
document.title = document.title + ' - ' + location.hostname
// load global config
this.get(this.resolve('/api/v1/webui'), false, function (response) {
mailbox.uiConfig = response.data
if (mailbox.uiConfig.Label) {
document.title = document.title + ' - ' + mailbox.uiConfig.Label
} else {
document.title = document.title + ' - ' + location.hostname
}
})
},
@@ -36,4 +43,5 @@ export default {
<RouterView />
<Favicon />
<Notifications />
<EditTags />
</template>

View File

@@ -26,15 +26,14 @@ export default {
},
methods: {
loadInfo: function () {
let self = this
self.get(self.resolve('/api/v1/info'), false, function (response) {
loadInfo() {
this.get(this.resolve('/api/v1/info'), false, (response) => {
mailbox.appInfo = response.data
self.modal('AppInfoModal').show()
this.modal('AppInfoModal').show()
})
},
requestNotifications: function () {
requestNotifications() {
// check if the browser supports notifications
if (!("Notification" in window)) {
alert("This browser does not support desktop notifications")
@@ -42,7 +41,6 @@ export default {
// we need to ask the user for permission
else if (Notification.permission !== "denied") {
let self = this
Notification.requestPermission().then(function (permission) {
if (permission === "granted") {
mailbox.notificationsEnabled = true
@@ -93,16 +91,20 @@ export default {
<div class="row g-3">
<div class="col-xl-6">
<div class="row g-3" v-if="mailbox.appInfo.LatestVersion == ''">
<div class="alert alert-warning mb-3">
There might be a newer version available. The check failed.
<div class="col">
<div class="alert alert-warning mb-3">
There might be a newer version available. The check failed.
</div>
</div>
</div>
<div class="row g-3"
v-else-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion">
<a class="btn btn-warning d-block mb-3"
:href="'https://github.com/axllent/mailpit/releases/tag/' + mailbox.appInfo.LatestVersion">
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is available.
</a>
<div class="col">
<a class="btn btn-warning d-block mb-3"
:href="'https://github.com/axllent/mailpit/releases/tag/' + mailbox.appInfo.LatestVersion">
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is available.
</a>
</div>
</div>
<div class="row g-3">
<div class="col-12">
@@ -180,7 +182,9 @@ export default {
<td>
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }}
<small class="text-secondary">
({{ getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize) }})
({{
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize)
}})
</small>
</td>
</tr>
@@ -245,6 +249,6 @@ export default {
<Settings />
</template>
<AjaxLoader :loading="loading" />
</template>

View File

@@ -0,0 +1,119 @@
<script>
import CommonMixins from '../mixins/CommonMixins'
import { mailbox } from '../stores/mailbox'
export default {
mixins: [CommonMixins],
data() {
return {
mailbox,
editableTags: [],
validTagRe: new RegExp(/^([a-zA-Z0-9\-\ \_\.]){1,}$/),
tagToDelete: false,
}
},
watch: {
'mailbox.tags': {
handler(tags) {
this.editableTags = []
tags.forEach((t) => {
this.editableTags.push({ before: t, after: t })
})
},
deep: true
}
},
methods: {
validTag(t) {
if (!t.after.match(/^([a-zA-Z0-9\-\ \_\.]){1,}$/)) {
return false
}
const lower = t.after.toLowerCase()
for (let x = 0; x < this.editableTags.length; x++) {
if (this.editableTags[x].before != t.before && lower == this.editableTags[x].before.toLowerCase()) {
return false
}
}
return true
},
renameTag(t) {
if (!this.validTag(t) || t.before == t.after) {
return
}
this.put(this.resolve(`/api/v1/tags/` + encodeURI(t.before)), { Name: t.after }, () => {
// the API triggers a reload via websockets
})
},
deleteTag() {
this.delete(this.resolve(`/api/v1/tags/` + encodeURI(this.tagToDelete.before)), null, () => {
// the API triggers a reload via websockets
this.tagToDelete = false
})
},
resetTagEdit(t) {
for (let x = 0; x < this.editableTags.length; x++) {
if (this.editableTags[x].before != t.before && this.editableTags[x].before != this.editableTags[x].after) {
this.editableTags[x].after = this.editableTags[x].before
}
}
}
}
}
</script>
<template>
<div class="modal fade" id="EditTagsModal" tabindex="-1" aria-labelledby="EditTagsModalLabel" aria-hidden="true"
data-bs-keyboard="false">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="EditTagsModalLabel">Edit tags</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
Renaming a tag will update the tag for all messages. Deleting a tag will only delete the tag
itself, and not any messages which had the tag.
</p>
<div class="mb-3" v-for="t in editableTags">
<div class="input-group has-validation">
<input type="text" class="form-control" :class="!validTag(t) ? 'is-invalid' : ''"
v-model.trim="t.after" aria-describedby="inputGroupPrepend" required
@keydown.enter="renameTag(t)" @keydown.esc="t.after = t.before"
@focus="resetTagEdit(t)">
<button v-if="t.before != t.after" class="btn btn-success"
@click="renameTag(t)">Save</button>
<template v-else>
<button class="btn btn-outline-danger"
:class="tagToDelete.before == t.before ? 'text-white btn-danger' : ''"
@click="!tagToDelete ? tagToDelete = t : deleteTag()" @blur="tagToDelete = false">
<template v-if="tagToDelete == t">
Confirm?
</template>
<template v-else>
Delete
</template>
</button>
</template>
<div class="invalid-feedback">
Invalid tag name
</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

@@ -39,10 +39,9 @@ export default {
}
this.iconProcessing = true
let self = this
window.setTimeout(() => {
self.icoUpdate()
this.icoUpdate()
}, this.iconTimeout)
},
},

View File

@@ -2,6 +2,7 @@
import { mailbox } from '../stores/mailbox'
import CommonMixins from '../mixins/CommonMixins'
import dayjs from 'dayjs'
import { pagination } from "../stores/pagination";
export default {
mixins: [
@@ -15,33 +16,30 @@ export default {
data() {
return {
mailbox,
pagination,
}
},
mounted() {
let relativeTime = require('dayjs/plugin/relativeTime')
const relativeTime = require('dayjs/plugin/relativeTime')
dayjs.extend(relativeTime)
this.refreshUI()
},
methods: {
refreshUI: function () {
let self = this
window.setTimeout(
() => {
self.$forceUpdate()
self.refreshUI()
},
30000
)
refreshUI() {
window.setTimeout(() => {
this.$forceUpdate()
this.refreshUI()
}, 30000)
},
getRelativeCreated: function (message) {
let d = new Date(message.Created)
getRelativeCreated(message) {
const d = new Date(message.Created)
return dayjs(d).fromNow()
},
getPrimaryEmailTo: function (message) {
getPrimaryEmailTo(message) {
for (let i in message.To) {
return message.To[i].Address
}
@@ -49,11 +47,11 @@ export default {
return '[ Undisclosed recipients ]'
},
isSelected: function (id) {
isSelected(id) {
return mailbox.selected.indexOf(id) != -1
},
toggleSelected: function (e, id) {
toggleSelected(e, id) {
e.preventDefault()
if (this.isSelected(id)) {
@@ -65,7 +63,7 @@ export default {
}
},
selectRange: function (e, id) {
selectRange(e, id) {
e.preventDefault()
let selecting = false
@@ -99,6 +97,20 @@ export default {
}
}
},
toTagUrl(t) {
if (t.match(/ /)) {
t = `"${t}"`
}
const p = {
q: 'tag:' + t
}
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
}
const params = new URLSearchParams(p)
return '/search?' + params.toString()
},
}
}
</script>
@@ -117,16 +129,14 @@ export default {
{{ getRelativeCreated(message) }}
</div>
<div class="text-truncate d-lg-none privacy">
<span v-if="message.From" :title="'From: ' + message.From.Address">{{
message.From.Name ?
message.From.Name : message.From.Address
}}</span>
<span v-if="message.From" :title="'From: ' + message.From.Address">
{{ message.From.Name ? message.From.Name : message.From.Address }}
</span>
</div>
<div class="text-truncate d-none d-lg-block privacy">
<b v-if="message.From" :title="'From: ' + message.From.Address">{{
message.From.Name ?
message.From.Name : message.From.Address
}}</b>
<b v-if="message.From" :title="'From: ' + message.From.Address">
{{ message.From.Name ? message.From.Name : message.From.Address }}
</b>
</div>
<div class="d-none d-lg-block text-truncate text-muted small privacy">
{{ getPrimaryEmailTo(message) }}
@@ -143,7 +153,8 @@ export default {
{{ message.Snippet }}
</div>
<div v-if="message.Tags.length">
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="'/search?q=' + tagEncodeURI(t)"
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="toTagUrl(t)"
v-on:click="pagination.start = 0"
:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }"
:title="'Filter messages tagged with ' + t">
{{ t }}

View File

@@ -30,30 +30,33 @@ export default {
},
methods: {
reloadInbox: function () {
pagination.start = 0
this.loadMessages()
reloadInbox() {
const paginationParams = this.getPaginationParams()
const reload = paginationParams?.start ? false : true
this.$router.push('/')
if (reload) {
// already on first page, reload messages
this.loadMessages()
}
},
loadMessages: function () {
loadMessages() {
this.hideNav() // hide mobile menu
this.$emit('loadMessages')
},
markAllRead: function () {
let self = this
self.put(self.resolve(`/api/v1/messages`), { 'read': true }, function (response) {
markAllRead() {
this.put(this.resolve(`/api/v1/messages`), { 'read': true }, (response) => {
window.scrollInPlace = true
self.loadMessages()
this.loadMessages()
})
},
deleteAllMessages: function () {
let self = this
self.delete(self.resolve(`/api/v1/messages`), false, function (response) {
deleteAllMessages() {
this.delete(this.resolve(`/api/v1/messages`), false, (response) => {
pagination.start = 0
self.loadMessages()
this.loadMessages()
})
}
}
@@ -62,7 +65,12 @@ export default {
<template>
<template v-if="!modals">
<div class="list-group my-2">
<div class="text-center badge text-bg-primary py-2 mt-2 w-100 text-truncate fw-normal"
v-if="mailbox.uiConfig.Label">
{{ mailbox.uiConfig.Label }}
</div>
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
<button @click="reloadInbox" class="list-group-item list-group-item-action active">
<i class="bi bi-envelope-fill me-1" v-if="mailbox.connected"></i>
<i class="bi bi-arrow-clockwise me-1" v-else></i>
@@ -114,7 +122,8 @@ export default {
</div>
</div>
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" aria-hidden="true">
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
@@ -122,8 +131,8 @@ export default {
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
This will permanently delete {{ formatNumber(mailbox.count) }}
message<span v-if="mailbox.count > 1">s</span>.
This will permanently delete {{ formatNumber(mailbox.total) }}
message<span v-if="mailbox.total > 1">s</span>.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>

View File

@@ -30,22 +30,20 @@ export default {
},
methods: {
loadMessages: function () {
loadMessages() {
this.hideNav() // hide mobile menu
this.$emit('loadMessages')
},
deleteAllMessages: function () {
let s = this.getSearch()
deleteAllMessages() {
const s = this.getSearch()
if (!s) {
return
}
let self = this
let uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
this.delete(uri, false, function (response) {
self.$router.push('/')
const uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
this.delete(uri, false, (response) => {
this.$router.push('/')
})
}
}
@@ -54,7 +52,12 @@ export default {
<template>
<template v-if="!modals">
<div class="list-group my-2">
<div class="text-center badge text-bg-primary py-2 mt-2 w-100 text-truncate fw-normal"
v-if="mailbox.uiConfig.Label">
{{ mailbox.uiConfig.Label }}
</div>
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
<RouterLink to="/" class="list-group-item list-group-item-action" @click="pagination.start = 0">
<i class="bi bi-arrow-return-left me-1"></i>
<span class="ms-1">Inbox</span>
@@ -77,7 +80,8 @@ export default {
<template v-else>
<!-- Modals -->
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" aria-hidden="true">
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">

View File

@@ -19,54 +19,52 @@ export default {
},
methods: {
loadMessages: function () {
loadMessages() {
this.$emit('loadMessages')
},
// mark selected messages as read
markSelectedRead: function () {
let self = this
markSelectedRead() {
if (!mailbox.selected.length) {
return false
}
self.put(self.resolve(`/api/v1/messages`), { 'Read': true, 'IDs': mailbox.selected }, function (response) {
this.put(this.resolve(`/api/v1/messages`), { 'Read': true, 'IDs': mailbox.selected }, (response) => {
window.scrollInPlace = true
self.loadMessages()
this.loadMessages()
})
},
isSelected: function (id) {
isSelected(id) {
return mailbox.selected.indexOf(id) != -1
},
// mark selected messages as unread
markSelectedUnread: function () {
let self = this
markSelectedUnread() {
if (!mailbox.selected.length) {
return false
}
self.put(self.resolve(`/api/v1/messages`), { 'Read': false, 'IDs': mailbox.selected }, function (response) {
this.put(this.resolve(`/api/v1/messages`), { 'Read': false, 'IDs': mailbox.selected }, (response) => {
window.scrollInPlace = true
self.loadMessages()
this.loadMessages()
})
},
// universal handler to delete current or selected messages
deleteMessages: function () {
deleteMessages() {
let ids = []
let self = this
ids = JSON.parse(JSON.stringify(mailbox.selected))
if (!ids.length) {
return false
}
self.delete(self.resolve(`/api/v1/messages`), { 'IDs': ids }, function (response) {
this.delete(this.resolve(`/api/v1/messages`), { 'IDs': ids }, (response) => {
window.scrollInPlace = true
self.loadMessages()
this.loadMessages()
})
},
// test if any selected emails are unread
selectedHasUnread: function () {
selectedHasUnread() {
if (!mailbox.selected.length) {
return false
}
@@ -79,7 +77,7 @@ export default {
},
// test of any selected emails are read
selectedHasRead: function () {
selectedHasRead() {
if (!mailbox.selected.length) {
return false
}

View File

@@ -11,27 +11,13 @@ export default {
data() {
return {
mailbox,
pagination,
}
},
methods: {
// if the current filter is active then reload view
reloadFilter: function (tag) {
const urlParams = new URLSearchParams(window.location.search)
const query = urlParams.get('q')
if (!query) {
return false
}
let re = new RegExp(`^tag:"?${tag}"?$`, 'i')
if (query.match(re)) {
pagination.start = 0
this.$emit('loadMessages')
}
},
// test whether a tag is currently being searched for (in the URL)
inSearch: function (tag) {
inSearch(tag) {
const urlParams = new URLSearchParams(window.location.search)
const query = urlParams.get('q')
if (!query) {
@@ -43,7 +29,7 @@ export default {
},
// toggle a tag search in the search URL, add or remove it accordingly
toggleTag: function (e, tag) {
toggleTag(e, tag) {
e.preventDefault()
const urlParams = new URLSearchParams(window.location.search)
@@ -67,9 +53,28 @@ export default {
if (query == '') {
this.$router.push('/')
} else {
this.$router.push('/search?q=' + encodeURIComponent(query))
const params = new URLSearchParams({
q: query,
start: pagination.start.toString(),
limit: pagination.limit.toString(),
})
this.$router.push('/search?' + params.toString())
}
}
},
toTagUrl(t) {
if (t.match(/ /)) {
t = `"${t}"`
}
const p = {
q: 'tag:' + t
}
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
}
const params = new URLSearchParams(p)
return '/search?' + params.toString()
},
}
}
</script>
@@ -81,6 +86,11 @@ export default {
Tags
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" data-bs-toggle="modal" data-bs-target="#EditTagsModal">
Edit tags
</button>
</li>
<li>
<button class="dropdown-item" @click="mailbox.showTagColors = !mailbox.showTagColors">
<template v-if="mailbox.showTagColors">Hide</template>
@@ -91,8 +101,8 @@ export default {
</ul>
</div>
<div class="list-group mt-1 mb-5 pb-3">
<RouterLink v-for="tag in mailbox.tags" :to="'/search?q=' + tagEncodeURI(tag)" @click="hideNav"
v-on:click="reloadFilter(tag)" v-on:click.ctrl="toggleTag($event, tag)"
<RouterLink v-for="tag in mailbox.tags" :to="toTagUrl(tag)" @click="hideNav"
v-on:click="pagination.start = 0" v-on:click.ctrl="toggleTag($event, tag)"
:style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"
class="list-group-item list-group-item-action small px-2" :class="inSearch(tag) ? 'active' : ''">
<i class="bi bi-tag-fill" v-if="inSearch(tag)"></i>

View File

@@ -14,20 +14,24 @@ export default {
toastMessage: false,
reconnectRefresh: false,
socketURI: false,
socketLastConnection: 0, // timestamp to track reconnection times & avoid reloading mailbox on short disconnections
socketBreaks: 0, // to track sockets that continually connect & disconnect, reset every 15s
pauseNotifications: false, // prevent spamming
version: false
version: false,
paginationDelayed: false, // for delayed pagination URL changes
}
},
mounted() {
let d = document.getElementById('app')
const d = document.getElementById('app')
if (d) {
this.version = d.dataset.version
}
let proto = location.protocol == 'https:' ? 'wss' : 'ws'
const proto = location.protocol == 'https:' ? 'wss' : 'ws'
this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`)
this.socketBreakReset()
this.connect()
mailbox.notificationsSupported = window.isSecureContext
@@ -37,10 +41,9 @@ export default {
methods: {
// websocket connect
connect: function () {
let ws = new WebSocket(this.socketURI)
let self = this
ws.onmessage = function (e) {
connect() {
const ws = new WebSocket(this.socketURI)
ws.onmessage = (e) => {
let response
try {
response = JSON.parse(e.data)
@@ -60,6 +63,8 @@ export default {
} else {
// update pagination offset
pagination.start++
// prevent "Too many calls to Location or History APIs within a short timeframe"
this.delayedPaginationUpdate()
}
}
@@ -73,13 +78,13 @@ export default {
}
// send notifications
if (!self.pauseNotifications) {
self.pauseNotifications = true
if (!this.pauseNotifications) {
this.pauseNotifications = true
let from = response.Data.From != null ? response.Data.From.Address : '[unknown]'
self.browserNotify("New mail from: " + from, response.Data.Subject)
self.setMessageToast(response.Data)
this.browserNotify("New mail from: " + from, response.Data.Subject)
this.setMessageToast(response.Data)
// delay notifications by 2s
window.setTimeout(() => { self.pauseNotifications = false }, 2000)
window.setTimeout(() => { this.pauseNotifications = false }, 2000)
}
} else if (response.Type == "prune") {
// messages have been deleted, reload messages to adjust
@@ -92,27 +97,52 @@ export default {
mailbox.unread = response.Data.Unread
// detect version updated, refresh is needed
if (self.version != response.Data.Version) {
if (this.version != response.Data.Version) {
location.reload()
}
}
}
ws.onopen = function () {
ws.onopen = () => {
mailbox.connected = true
if (self.reconnectRefresh) {
self.reconnectRefresh = false
this.socketLastConnection = Date.now()
if (this.reconnectRefresh) {
this.reconnectRefresh = false
mailbox.refresh = true // trigger refresh
window.setTimeout(() => { mailbox.refresh = false }, 500)
}
}
ws.onclose = function (e) {
mailbox.connected = false
self.reconnectRefresh = true
ws.onclose = (e) => {
if (this.socketLastConnection == 0) {
// connection failed immediately after connecting to Mailpit implies proxy websockets aren't configured
console.log('Unable to connect to websocket, disabling websocket support')
return
}
setTimeout(function () {
self.connect() // reconnect
if (mailbox.connected) {
// count disconnections
this.socketBreaks++
}
// set disconnected state
mailbox.connected = false
if (this.socketBreaks > 3) {
// give up after > 3 successful socket connections & disconnections within a 15 second window,
// something is not working right on their end, see issue #319
console.log('Unstable websocket connection, disabling websocket support')
return
}
if (Date.now() - this.socketLastConnection > 5000) {
// only refresh mailbox if the last successful connection was broken for > 5 seconds
this.reconnectRefresh = true
} else {
this.reconnectRefresh = false
}
setTimeout(() => {
this.connect() // reconnect
}, 1000)
}
@@ -121,13 +151,52 @@ export default {
}
},
browserNotify: function (title, message) {
socketBreakReset() {
window.setTimeout(() => {
this.socketBreaks = 0
this.socketBreakReset()
}, 15000)
},
// This will only update the pagination offset at a maximum of 2x per second
// when viewing the inbox on > page 1, while receiving an influx of new messages.
delayedPaginationUpdate() {
if (this.paginationDelayed) {
return
}
this.paginationDelayed = true
window.setTimeout(() => {
const path = this.$route.path
const p = {
...this.$route.query
}
if (pagination.start > 0) {
p.start = pagination.start.toString()
} else {
delete p.start
}
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
} else {
delete p.limit
}
mailbox.autoPaginating = false // prevent reload of messages when URL changes
const params = new URLSearchParams(p)
this.$router.replace(path + '?' + params.toString())
this.paginationDelayed = false
}, 500)
},
browserNotify(title, message) {
if (!("Notification" in window)) {
return
}
if (Notification.permission === "granted") {
let b = message.Subject
let options = {
body: message,
icon: this.resolve('/notification.png')
@@ -136,7 +205,7 @@ export default {
}
},
setMessageToast: function (m) {
setMessageToast(m) {
// don't display if browser notifications are enabled, or a toast is already displayed
if (mailbox.notificationsEnabled || this.toastMessage) {
return
@@ -144,19 +213,18 @@ export default {
this.toastMessage = m
let self = this
let el = document.getElementById('messageToast')
const el = document.getElementById('messageToast')
if (el) {
el.addEventListener('hidden.bs.toast', () => {
self.toastMessage = false
this.toastMessage = false
})
Toast.getOrCreateInstance(el).show()
}
},
closeToast: function () {
let el = document.getElementById('messageToast')
closeToast() {
const el = document.getElementById('messageToast')
if (el) {
Toast.getOrCreateInstance(el).hide()
}

View File

@@ -1,7 +1,7 @@
<script>
import CommonMixins from '../mixins/CommonMixins'
import { mailbox } from '../stores/mailbox'
import { pagination } from '../stores/pagination'
import { limitOptions, pagination } from '../stores/pagination'
export default {
@@ -11,26 +11,25 @@ export default {
total: Number,
},
emits: ['loadMessages'],
data() {
return {
pagination,
mailbox,
limitOptions,
}
},
computed: {
canPrev: function () {
canPrev() {
return pagination.start > 0
},
canNext: function () {
canNext() {
return this.total > (pagination.start + mailbox.messages.length)
},
// returns the number of next X messages
nextMessages: function () {
nextMessages() {
let t = pagination.start + parseInt(pagination.limit, 10)
if (t > this.total) {
t = this.total
@@ -41,23 +40,42 @@ export default {
},
methods: {
changeLimit: function () {
changeLimit() {
pagination.start = 0
this.$emit('loadMessages')
this.updateQueryParams()
},
viewNext: function () {
viewNext() {
pagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10)
this.$emit('loadMessages')
this.updateQueryParams()
},
viewPrev: function () {
viewPrev() {
let s = pagination.start - pagination.limit
if (s < 0) {
s = 0
}
pagination.start = s
this.$emit('loadMessages')
this.updateQueryParams()
},
updateQueryParams() {
const path = this.$route.path
const p = {
...this.$route.query
}
if (pagination.start > 0) {
p.start = pagination.start.toString()
} else {
delete p.start
}
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
} else {
delete p.limit
}
const params = new URLSearchParams(p)
this.$router.push(path + '?' + params.toString())
},
}
}
@@ -66,10 +84,7 @@ export default {
<template>
<select v-model="pagination.limit" @change="changeLimit" class="form-select form-select-sm d-inline w-auto me-2"
:disabled="total == 0">
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
<option v-for="option in limitOptions" :key="option" :value="option">{{ option }}</option>
</select>
<small>

View File

@@ -24,29 +24,40 @@ export default {
},
methods: {
searchFromURL: function () {
searchFromURL() {
const urlParams = new URLSearchParams(window.location.search)
this.search = urlParams.get('q') ? urlParams.get('q') : ''
},
doSearch: function (e) {
doSearch(e) {
pagination.start = 0
if (this.search == '') {
this.$router.push('/')
} else {
const urlParams = new URLSearchParams(window.location.search)
let curr = urlParams.get('q')
const curr = urlParams.get('q')
if (curr && curr == this.search) {
pagination.start = 0
this.$emit('loadMessages')
}
this.$router.push('/search?q=' + encodeURIComponent(this.search))
const p = {
q: this.search
}
if (pagination.start > 0) {
p.start = pagination.start.toString()
}
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
}
const params = new URLSearchParams(p)
this.$router.push('/search?' + params.toString())
}
e.preventDefault()
},
resetSearch: function () {
resetSearch() {
this.search = ''
this.$router.push('/')
}

View File

@@ -16,7 +16,7 @@ export default {
},
watch: {
theme: function(v) {
theme(v) {
if (v == 'auto') {
localStorage.removeItem('theme')
} else {
@@ -34,7 +34,7 @@ export default {
},
methods: {
setTheme: function () {
setTheme() {
if (
this.theme === 'auto' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
@@ -68,7 +68,7 @@ export default {
</div>
<div class="mb-3">
<label for="timezone" class="form-label">Timezone (for date searches)</label>
<select class="form-select tz" v-model="mailbox.timeZone" id="timezone">
<select class="form-select tz" v-model="mailbox.timeZone" id="timezone" data-allow-same="true">
<option disabled hidden value="">Select a timezone...</option>
<option v-for="t in timezones" :value="t.tzCode">{{ t.label }}</option>
</select>

View File

@@ -18,7 +18,7 @@ export default {
},
methods: {
openAttachment: function (part, e) {
openAttachment(part, e) {
let filename = part.FileName
let contentType = part.ContentType
let href = this.resolve('/api/v1/message/' + this.message.ID + '/part/' + part.PartID)

View File

@@ -41,9 +41,7 @@ export default {
},
computed: {
summary: function () {
let self = this
summary() {
if (!this.check) {
return false
}
@@ -65,8 +63,8 @@ export default {
}
// filter by enabled platforms
let results = o.Results.filter(function (w) {
return self.platforms.indexOf(w.Platform) != -1
let results = o.Results.filter((w) => {
return this.platforms.indexOf(w.Platform) != -1
})
if (results.length == 0) {
@@ -98,7 +96,7 @@ export default {
}
let maxPartial = 0, maxUnsupported = 0
result.Warnings.forEach(function (w) {
result.Warnings.forEach((w) => {
let scoreWeight = 1
if (w.Score.Found < result.Total.Nodes) {
// each error is weighted based on the number of occurrences vs: the total message nodes
@@ -108,7 +106,7 @@ export default {
// pseudo-classes & at-rules need to be weighted lower as we do not know how many times they
// are actually used in the HTML, and including things like bootstrap styles completely throws
// off the calculation as these dominate.
if (self.isPseudoClassOrAtRule(w.Title)) {
if (this.isPseudoClassOrAtRule(w.Title)) {
scoreWeight = 0.05
w.PseudoClassOrAtRule = true
}
@@ -124,15 +122,15 @@ export default {
})
// sort warnings by final score
result.Warnings.sort(function (a, b) {
result.Warnings.sort((a, b) => {
let aWeight = a.Score.Found > result.Total.Nodes ? result.Total.Nodes : a.Score.Found / result.Total.Nodes
let bWeight = b.Score.Found > result.Total.Nodes ? result.Total.Nodes : b.Score.Found / result.Total.Nodes
if (self.isPseudoClassOrAtRule(a.Title)) {
if (this.isPseudoClassOrAtRule(a.Title)) {
aWeight = 0.05
}
if (self.isPseudoClassOrAtRule(b.Title)) {
if (this.isPseudoClassOrAtRule(b.Title)) {
bWeight = 0.05
}
@@ -148,7 +146,7 @@ export default {
return result
},
graphSections: function () {
graphSections() {
let s = Math.round(this.summary.Total.Supported)
let p = Math.round(this.summary.Total.Partial)
let u = 100 - s - p
@@ -172,7 +170,7 @@ export default {
},
// colors depend on both varying unsupported & partially unsupported percentages
scoreColor: function () {
scoreColor() {
if (this.summary.Total.Unsupported < 5 && this.summary.Total.Partial < 10) {
this.$emit('setBadgeStyle', 'bg-success')
return 'text-success'
@@ -197,62 +195,51 @@ export default {
platforms(v) {
localStorage.setItem('html-check-platforms', JSON.stringify(v))
},
// enabled(v) {
// if (!v) {
// localStorage.setItem('htmlCheckDisabled', true)
// this.$emit('setHtmlScore', false)
// } else {
// localStorage.removeItem('htmlCheckDisabled')
// this.doCheck()
// }
// }
},
methods: {
doCheck: function () {
doCheck() {
this.check = false
if (this.message.HTML == "") {
return
}
let self = this
// ignore any error, do not show loader
axios.get(self.resolve('/api/v1/message/' + self.message.ID + '/html-check'), null)
.then(function (result) {
self.check = result.data
self.error = false
axios.get(this.resolve('/api/v1/message/' + this.message.ID + '/html-check'), null)
.then((result) => {
this.check = result.data
this.error = false
// set tooltips
window.setTimeout(function () {
window.setTimeout(() => {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl))
}, 500)
})
.catch(function (error) {
.catch((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) {
self.error = error.response.data.Error
this.error = error.response.data.Error
} else {
self.error = error.response.data
this.error = 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
self.error = 'Error sending data to the server. Please try again.'
this.error = 'Error sending data to the server. Please try again.'
} else {
// Something happened in setting up the request that triggered an Error
self.error = error.message
this.error = error.message
}
})
},
loadConfig: function () {
loadConfig() {
let platforms = localStorage.getItem('html-check-platforms')
if (platforms) {
try {
@@ -268,7 +255,7 @@ export default {
},
// return a platform's families (email clients)
families: function (k) {
families(k) {
if (this.check.Platforms[k]) {
return this.check.Platforms[k]
}
@@ -277,19 +264,19 @@ export default {
},
// return whether the test string is a pseudo class (:<test>) or at rule (@<test>)
isPseudoClassOrAtRule: function (t) {
isPseudoClassOrAtRule(t) {
return t.match(/^(:|@)/)
},
round: function (v) {
round(v) {
return Math.round(v)
},
round2dm: function (v) {
round2dm(v) {
return Math.round(v * 100) / 100
},
scrollToWarnings: function () {
scrollToWarnings() {
if (!this.$refs.warnings) {
return
}
@@ -312,9 +299,9 @@ export default {
<div class="mt-5 mb-3">
<div class="row w-100">
<div class="col-md-8">
<Donut :sections="graphSections" background="var(--bs-body-bg)" :size="180" unit="px" :thickness="20"
has-legend legend-placement="bottom" :total="100" :start-angle="0" :auto-adjust-text-size="true"
@section-click="scrollToWarnings">
<Donut :sections="graphSections" background="var(--bs-body-bg)" :size="180" unit="px"
:thickness="20" has-legend legend-placement="bottom" :total="100" :start-angle="0"
:auto-adjust-text-size="true" @section-click="scrollToWarnings">
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">
{{ round2dm(summary.Total.Supported) }}%
</h2>
@@ -388,8 +375,9 @@ export default {
<div class="col-sm mt-2 mt-sm-0">
<div class="progress-stacked">
<div class="progress" role="progressbar" aria-label="Supported"
:aria-valuenow="warning.Score.Supported" aria-valuemin="0" aria-valuemax="100"
:style="{ width: warning.Score.Supported + '%' }" title="Supported">
:aria-valuenow="warning.Score.Supported" aria-valuemin="0"
aria-valuemax="100" :style="{ width: warning.Score.Supported + '%' }"
title="Supported">
<div class="progress-bar bg-success">
{{ round(warning.Score.Supported) + '%' }}
</div>
@@ -402,8 +390,9 @@ export default {
</div>
</div>
<div class="progress" role="progressbar" aria-label="No"
:aria-valuenow="warning.Score.Unsupported" aria-valuemin="0" aria-valuemax="100"
:style="{ width: warning.Score.Unsupported + '%' }" title="Not supported">
:aria-valuenow="warning.Score.Unsupported" aria-valuemin="0"
aria-valuemax="100" :style="{ width: warning.Score.Unsupported + '%' }"
title="Not supported">
<div class="progress-bar bg-danger">
{{ round(warning.Score.Unsupported) + '%' }}
</div>
@@ -420,7 +409,8 @@ export default {
<i class="bi bi-info-circle me-2"></i>
Detected {{ warning.Score.Found }} <code>{{ warning.Title }}</code>
propert<template v-if="warning.Score.Found === 1">y</template><template
v-else>ies</template> in the CSS styles, but unable to test if used or not.
v-else>ies</template> in the CSS
styles, but unable to test if used or not.
</span>
<span v-if="warning.Description != ''" v-html="warning.Description" class="me-2"></span>
</p>
@@ -487,9 +477,9 @@ export default {
<div id="col1" class="accordion-collapse collapse"
data-bs-parent="#HTMLCheckAboutAccordion">
<div class="accordion-body">
The support for HTML/CSS messages varies greatly across email clients. HTML check
attempts to calculate the overall support for your email for all selected platforms
to give you some idea of the general compatibility of your HTML email.
The support for HTML/CSS messages varies greatly across email clients. HTML
check attempts to calculate the overall support for your email for all selected
platforms to give you some idea of the general compatibility of your HTML email.
</div>
</div>
</div>
@@ -507,29 +497,31 @@ export default {
Internally the original HTML message is run against
<b>{{ check.Total.Tests }}</b> different HTML and CSS tests. All tests
(except for <code>&lt;script&gt;</code>) correspond to a test on
<a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>, and the
final score is calculated using the available compatibility data.
<a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>, and
the final score is calculated using the available compatibility data.
</p>
<p>
CSS support is very difficult to programmatically test, especially if a message
contains CSS style blocks or is linked to remote stylesheets. Remote stylesheets
are, unless blocked via <code>--block-remote-css-and-fonts</code>, downloaded
and injected into the message as style blocks. The email is then
<a href="https://github.com/vanng822/go-premailer" target="_blank">inlined</a>
CSS support is very difficult to programmatically test, especially if a
message contains CSS style blocks or is linked to remote stylesheets. Remote
stylesheets are, unless blocked via
<code>--block-remote-css-and-fonts</code>,
downloaded and injected into the message as style blocks. The email is then
<a href="https://github.com/vanng822/go-premailer"
target="_blank">inlined</a>
to matching HTML elements. This gives Mailpit fairly accurate results.
</p>
<p>
CSS properties such as <code>@font-face</code>, <code>:visited</code>,
<code>:hover</code> etc cannot be inlined however, so these are searched for
within CSS blocks. This method is not accurate as Mailpit does not know how many
nodes it actually applies to, if any, so they are weighted lightly (5%) as not
to affect the score. An example of this would be any email linking to the full
bootstrap CSS which contains dozens of unused attributes.
within CSS blocks. This method is not accurate as Mailpit does not know how
many nodes it actually applies to, if any, so they are weighted lightly (5%)
as not to affect the score. An example of this would be any email linking to
the full bootstrap CSS which contains dozens of unused attributes.
</p>
<p>
All warnings are displayed with their respective support, including any specific
notes, and it is up to you to decide what you do with that information and how
badly it may impact your message.
All warnings are displayed with their respective support, including any
specific notes, and it is up to you to decide what you do with that
information and how badly it may impact your message.
</p>
</div>
</div>
@@ -552,13 +544,15 @@ export default {
<p>
For each test, Mailpit calculates both the unsupported & partially-supported
percentages in relation to the number of matches against the total number of
nodes (elements) in the HTML. The maximum unsupported and partially-supported
weighted scores are then used for the final score (ie: worst case scenario).
nodes (elements) in the HTML. The maximum unsupported and
partially-supported weighted scores are then used for the final score (ie:
worst case scenario).
</p>
<p>
To try explain this logic in very simple terms: Assuming a
<code>&lt;script&gt;</code> node (element) has 100% failure (not supported in
any email client), and a <code>&lt;p&gt;</code> node has 100% pass (supported).
<code>&lt;script&gt;</code> node (element) has 100% failure (not supported
in any email client), and a <code>&lt;p&gt;</code> node has 100% pass
(supported).
</p>
<ul>
<li>
@@ -575,7 +569,8 @@ export default {
</li>
</ul>
<p>
Mailpit will sort the warnings according to their weighted unsupported scores.
Mailpit will sort the warnings according to their weighted unsupported
scores.
</p>
</div>
</div>
@@ -591,9 +586,9 @@ export default {
<div id="col4" class="accordion-collapse collapse"
data-bs-parent="#HTMLCheckAboutAccordion">
<div class="accordion-body">
HTML check does not detect if the original HTML is valid. In order to detect applied
styles to every node, the HTML email is run through a parser which is very good at
turning invalid input into valid output. It is what it is...
HTML check does not detect if the original HTML is valid. In order to detect
applied styles to every node, the HTML email is run through a parser which is
very good at turning invalid input into valid output. It is what it is...
</div>
</div>
</div>

View File

@@ -1,4 +1,3 @@
<script>
import commonMixins from '../../mixins/CommonMixins'
@@ -16,10 +15,9 @@ export default {
},
mounted() {
let self = this;
let uri = self.resolve('/api/v1/message/' + self.message.ID + '/headers')
self.get(uri, false, function (response) {
self.headers = response.data
let uri = this.resolve('/api/v1/message/' + this.message.ID + '/headers')
this.get(uri, false, (response) => {
this.headers = response.data
});
},

View File

@@ -64,7 +64,7 @@ export default {
},
computed: {
groupedStatuses: function () {
groupedStatuses() {
let results = {}
if (!this.check) {
@@ -114,7 +114,7 @@ export default {
},
methods: {
doCheck: function () {
doCheck() {
this.check = false
this.loading = true
let uri = this.resolve('/api/v1/message/' + this.message.ID + '/link-check')
@@ -122,38 +122,37 @@ export default {
uri += '?follow=true'
}
let self = this
// ignore any error, do not show loader
axios.get(uri, null)
.then(function (result) {
self.check = result.data
self.error = false
.then((result) => {
this.check = result.data
this.error = false
self.$emit('setLinkErrors', result.data.Errors)
this.$emit('setLinkErrors', result.data.Errors)
})
.catch(function (error) {
.catch((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) {
self.error = error.response.data.Error
this.error = error.response.data.Error
} else {
self.error = error.response.data
this.error = 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
self.error = 'Error sending data to the server. Please try again.'
this.error = 'Error sending data to the server. Please try again.'
} else {
// Something happened in setting up the request that triggered an Error
self.error = error.message
this.error = error.message
}
})
.then(function (result) {
.then((result) => {
// always run
self.loading = false
this.loading = false
})
},
}
@@ -239,7 +238,8 @@ export default {
</div>
<div class="modal fade" id="LinkCheckOptions" tabindex="-1" aria-labelledby="LinkCheckOptionsLabel" aria-hidden="true">
<div class="modal fade" id="LinkCheckOptions" tabindex="-1" aria-labelledby="LinkCheckOptionsLabel"
aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
@@ -296,11 +296,12 @@ export default {
What is Link check?
</button>
</h2>
<div id="col1" class="accordion-collapse collapse" data-bs-parent="#LinkCheckAboutAccordion">
<div id="col1" class="accordion-collapse collapse"
data-bs-parent="#LinkCheckAboutAccordion">
<div class="accordion-body">
Link check scans your message HTML and text for all unique links, images and linked
stylesheets. It then does a HTTP <code>HEAD</code> request to each link, 5 at a time, to
test whether the link/image/stylesheet exists.
stylesheets. It then does a HTTP <code>HEAD</code> request to each link, 5 at a
time, to test whether the link/image/stylesheet exists.
</div>
</div>
</div>
@@ -311,14 +312,16 @@ export default {
What are "301" and "302" links?
</button>
</h2>
<div id="col2" class="accordion-collapse collapse" data-bs-parent="#LinkCheckAboutAccordion">
<div id="col2" class="accordion-collapse collapse"
data-bs-parent="#LinkCheckAboutAccordion">
<div class="accordion-body">
<p>
These are links that redirect you to another URL, for example newsletters
often use redirect links to track user clicks.
</p>
<p>
By default Link check will not follow these links, however you can turn this on via
By default Link check will not follow these links, however you can turn this on
via
the settings and Link check will "follow" those redirects.
</p>
</div>
@@ -331,7 +334,8 @@ export default {
Why are some links returning an error but work in my browser?
</button>
</h2>
<div id="col3" class="accordion-collapse collapse" data-bs-parent="#LinkCheckAboutAccordion">
<div id="col3" class="accordion-collapse collapse"
data-bs-parent="#LinkCheckAboutAccordion">
<div class="accordion-body">
<p>This may be due to various reasons, for instance:</p>
<ul>
@@ -353,11 +357,12 @@ export default {
What are the risks of running Link check automatically?
</button>
</h2>
<div id="col4" class="accordion-collapse collapse" data-bs-parent="#LinkCheckAboutAccordion">
<div id="col4" class="accordion-collapse collapse"
data-bs-parent="#LinkCheckAboutAccordion">
<div class="accordion-body">
<p>
Depending on the type of messages you are testing, opening all links on all messages
may have undesired consequences:
Depending on the type of messages you are testing, opening all links on all
messages may have undesired consequences:
</p>
<ul>
<li>If the message contains tracking links this may reveal your identity.</li>
@@ -366,13 +371,13 @@ export default {
unsubscribe you.
</li>
<li>
To speed up the checking process, Link check will attempt 5 URLs at a time. This
could lead to temporary heady load on the remote server.
To speed up the checking process, Link check will attempt 5 URLs at a time.
This could lead to temporary heady load on the remote server.
</li>
</ul>
<p>
Unless you know what messages you receive, it is advised to only run the Link check
manually.
Unless you know what messages you receive, it is advised to only run the Link
check manually.
</p>
</div>
</div>

View File

@@ -61,16 +61,15 @@ export default {
scaleHTMLPreview(v) {
if (v == 'display') {
let self = this
window.setTimeout(function () {
self.resizeIFrames()
window.setTimeout(() => {
this.resizeIFrames()
}, 500)
}
}
},
computed: {
hasAnyChecksEnabled: function () {
hasAnyChecksEnabled() {
return (mailbox.showHTMLCheck && this.message.HTML)
|| mailbox.showLinkCheck
|| (mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin)
@@ -78,56 +77,53 @@ export default {
},
mounted() {
let self = this
self.canSaveTags = false
self.messageTags = self.message.Tags
self.renderUI()
this.canSaveTags = false
this.messageTags = this.message.Tags
this.renderUI()
window.addEventListener("resize", self.resizeIFrames)
window.addEventListener("resize", this.resizeIFrames)
let headersTab = document.getElementById('nav-headers-tab')
headersTab.addEventListener('shown.bs.tab', function (event) {
self.loadHeaders = true
headersTab.addEventListener('shown.bs.tab', (event) => {
this.loadHeaders = true
})
let rawTab = document.getElementById('nav-raw-tab')
rawTab.addEventListener('shown.bs.tab', function (event) {
self.srcURI = self.resolve('/api/v1/message/' + self.message.ID + '/raw')
self.resizeIFrames()
rawTab.addEventListener('shown.bs.tab', (event) => {
this.srcURI = this.resolve('/api/v1/message/' + this.message.ID + '/raw')
this.resizeIFrames()
})
// manually refresh tags
self.get(self.resolve(`/api/v1/tags`), false, function (response) {
self.availableTags = response.data
self.$nextTick(function () {
this.get(this.resolve(`/api/v1/tags`), false, (response) => {
this.availableTags = response.data
this.$nextTick(() => {
Tags.init('select[multiple]')
// delay tag change detection to allow Tags to load
window.setTimeout(function () {
self.canSaveTags = true
window.setTimeout(() => {
this.canSaveTags = true
}, 200)
})
})
},
methods: {
isHTMLTabSelected: function () {
isHTMLTabSelected() {
this.showMobileButtons = this.$refs.navhtml
&& this.$refs.navhtml.classList.contains('active')
},
renderUI: function () {
let self = this
renderUI() {
// activate the first non-disabled tab
document.querySelector('#nav-tab button:not([disabled])').click()
document.activeElement.blur() // blur focus
document.getElementById('message-view').scrollTop = 0
self.isHTMLTabSelected()
this.isHTMLTabSelected()
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(function (listObj) {
listObj.addEventListener('shown.bs.tab', function (event) {
self.isHTMLTabSelected()
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach((listObj) => {
listObj.addEventListener('shown.bs.tab', (event) => {
this.isHTMLTabSelected()
})
})
@@ -135,7 +131,7 @@ export default {
[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl))
// delay 0.2s until vue has rendered the iframe content
window.setTimeout(function () {
window.setTimeout(() => {
let p = document.getElementById('preview-html')
if (p) {
// make links open in new window
@@ -148,7 +144,7 @@ export default {
anchorEl.setAttribute('target', '_blank')
}
}
self.resizeIFrames()
this.resizeIFrames()
}
}, 200)
@@ -158,12 +154,12 @@ export default {
Prism.highlightAll()
},
resizeIframe: function (el) {
resizeIframe(el) {
let i = el.target
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px'
},
resizeIFrames: function () {
resizeIFrames() {
if (this.scaleHTMLPreview != 'display') {
return
}
@@ -175,7 +171,7 @@ export default {
},
// set the iframe body & text colors based on current theme
initRawIframe: function (el) {
initRawIframe(el) {
let bodyStyles = window.getComputedStyle(document.body, null)
let bg = bodyStyles.getPropertyValue('background-color')
let txt = bodyStyles.getPropertyValue('color')
@@ -189,27 +185,25 @@ export default {
this.resizeIframe(el)
},
sanitizeHTML: function (h) {
sanitizeHTML(h) {
// remove <base/> tag if set
return h.replace(/<base .*>/mi, '')
},
saveTags: function () {
let self = this
saveTags() {
var data = {
IDs: [this.message.ID],
Tags: this.messageTags
}
self.put(self.resolve('/api/v1/tags'), data, function (response) {
this.put(this.resolve('/api/v1/tags'), data, (response) => {
window.scrollInPlace = true
self.$emit('loadMessages')
this.$emit('loadMessages')
})
},
// Convert plain text to HTML including anchor links
textToHTML: function (s) {
textToHTML(s) {
let html = s
// full links with http(s)

View File

@@ -46,26 +46,25 @@ export default {
methods: {
// triggered manually after modal is shown
initTags: function () {
initTags() {
Tags.init("select[multiple]")
},
releaseMessage: function () {
let self = this
releaseMessage() {
// set timeout to allow for user clicking send before the tag filter has applied the tag
window.setTimeout(function () {
if (!self.addresses.length) {
window.setTimeout(() => {
if (!this.addresses.length) {
return false
}
let data = {
To: self.addresses
To: this.addresses
}
self.post(self.resolve('/api/v1/message/' + self.message.ID + '/release'), data, function (response) {
self.modal("ReleaseModal").hide()
if (self.deleteAfterRelease) {
self.$emit('delete')
this.post(this.resolve('/api/v1/message/' + this.message.ID + '/release'), data, (response) => {
this.modal("ReleaseModal").hide()
if (this.deleteAfterRelease) {
this.$emit('delete')
}
})
}, 100)
@@ -76,7 +75,7 @@ export default {
<template>
<div class="modal fade" id="ReleaseModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" v-if="message">
<div class="modal-dialog modal-xl" v-if="message">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="AppInfoModalLabel">Release email</h1>
@@ -126,11 +125,39 @@ export default {
</div>
</div>
<div class="form-text text-center" v-if="mailbox.uiConfig.MessageRelay.AllowedRecipients != ''">
<h6>Notes</h6>
<ul>
<li v-if="mailbox.uiConfig.MessageRelay.AllowedRecipients != ''" class="form-text">
A recipient <b>allowlist</b> has been configured. Any mail address not matching the following will be rejected:
<code>{{ mailbox.uiConfig.MessageRelay.AllowedRecipients }}</code>
</li>
<li v-if="mailbox.uiConfig.MessageRelay.BlockedRecipients != ''" class="form-text">
A recipient <b>blocklist</b> has been configured. Any mail address matching the following will be rejected:
<code>{{ mailbox.uiConfig.MessageRelay.BlockedRecipients }}</code>
</li>
<li class="form-text">
For testing purposes, a new unique <code>Message-Id</code> will be generated on send.
</li>
<li class="form-text">
SMTP delivery failures will bounce back to
<code v-if="mailbox.uiConfig.MessageRelay.ReturnPath != ''">
{{ mailbox.uiConfig.MessageRelay.ReturnPath }}
</code>
<code v-else>{{ message.ReturnPath }}</code>.
</li>
</ul>
<!-- <div class="form-text text-center" v-if="mailbox.uiConfig.MessageRelay.AllowedRecipients != ''">
Note: A recipient allowlist has been configured. Any mail address not matching it will be
rejected.<br class="d-none d-md-inline">
Allowed recipients: <b>{{ mailbox.uiConfig.MessageRelay.AllowedRecipients }}</b>
</div>
<div class="form-text text-center" v-if="mailbox.uiConfig.MessageRelay.BlockedRecipients != ''">
Note: A recipient blocklist has been configured. Any mail address matching it will be
rejected.<br class="d-none d-md-inline">
Blocked recipients: <b>{{ mailbox.uiConfig.MessageRelay.BlockedRecipients }}</b>
</div>
<div class="form-text text-center">
Note: For testing purposes, a unique Message-Id will be generated on send.
<br class="d-none d-md-inline">
@@ -139,7 +166,7 @@ export default {
{{ mailbox.uiConfig.MessageRelay.ReturnPath }}
</b>
<b v-else>{{ message.ReturnPath }}</b>.
</div>
</div> -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>

View File

@@ -1,4 +1,3 @@
<script>
import AjaxLoader from '../AjaxLoader.vue'
import CommonMixins from '../../mixins/CommonMixins'
@@ -23,9 +22,8 @@ export default {
},
methods: {
initScreenshot: function () {
initScreenshot() {
this.loading = 1
let self = this
// remove base tag, if set
let h = this.message.HTML.replace(/<base .*>/mi, '')
let proxy = this.resolve('/proxy')
@@ -38,11 +36,11 @@ export default {
// update any inline `url(...)` absolute links
const urlRegex = /(url\((\'|\")?(https?:\/\/[^\)\'\"]+)(\'|\")?\))/mgi;
h = h.replaceAll(urlRegex, function (match, p1, p2, p3) {
h = h.replaceAll(urlRegex, (match, p1, p2, p3) => {
if (typeof p2 === 'string') {
return `url(${p2}${proxy}?url=` + encodeURIComponent(self.decodeEntities(p3)) + `${p2})`
return `url(${p2}${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `${p2})`
}
return `url(${proxy}?url=` + encodeURIComponent(self.decodeEntities(p3)) + `)`
return `url(${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `)`
})
// create temporary document to manipulate
@@ -63,7 +61,7 @@ export default {
let src = i.getAttribute('href')
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
i.setAttribute('href', `${proxy}?url=` + encodeURIComponent(self.decodeEntities(src)))
i.setAttribute('href', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
}
}
@@ -72,7 +70,7 @@ export default {
for (let i of images) {
let src = i.getAttribute('src')
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
i.setAttribute('src', `${proxy}?url=` + encodeURIComponent(self.decodeEntities(src)))
i.setAttribute('src', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
}
}
@@ -83,7 +81,7 @@ export default {
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
// replace with proxy link
i.setAttribute('background', `${proxy}?url=` + encodeURIComponent(self.decodeEntities(src)))
i.setAttribute('background', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
}
}
@@ -92,7 +90,7 @@ export default {
},
// HTML decode function
decodeEntities: function (s) {
decodeEntities(s) {
let e = document.createElement('div')
e.innerHTML = s
let str = e.textContent
@@ -100,8 +98,7 @@ export default {
return str
},
doScreenshot: function () {
let self = this
doScreenshot() {
let width = document.getElementById('message-view').getBoundingClientRect().width
let prev = document.getElementById('preview-html')
@@ -113,7 +110,7 @@ export default {
width = 300
}
let i = document.getElementById('screenshot-html')
const i = document.getElementById('screenshot-html')
// set the iframe width
i.style.width = width + 'px'
@@ -127,11 +124,11 @@ export default {
width: width,
}).then(dataUrl => {
const link = document.createElement('a')
link.download = self.message.ID + '.png'
link.download = this.message.ID + '.png'
link.href = dataUrl
link.click()
self.loading = 0
self.html = false
this.loading = 0
this.html = false
})
}
}

View File

@@ -38,41 +38,39 @@ export default {
},
methods: {
doCheck: function () {
doCheck() {
this.check = false
let self = this
// ignore any error, do not show loader
axios.get(self.resolve('/api/v1/message/' + self.message.ID + '/sa-check'), null)
.then(function (result) {
self.check = result.data
self.error = false
self.setIcons()
axios.get(this.resolve('/api/v1/message/' + this.message.ID + '/sa-check'), null)
.then((result) => {
this.check = result.data
this.error = false
this.setIcons()
})
.catch(function (error) {
.catch((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) {
self.error = error.response.data.Error
this.error = error.response.data.Error
} else {
self.error = error.response.data
this.error = 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
self.error = 'Error sending data to the server. Please try again.'
this.error = 'Error sending data to the server. Please try again.'
} else {
// Something happened in setting up the request that triggered an Error
self.error = error.message
this.error = error.message
}
})
},
badgeStyle: function (ignorePadding = false) {
badgeStyle(ignorePadding = false) {
let badgeStyle = 'bg-success'
if (this.check.Error) {
badgeStyle = 'bg-warning text-primary'
@@ -90,7 +88,7 @@ export default {
return badgeStyle
},
setIcons: function () {
setIcons() {
let score = this.check.Score
if (this.check.Error && this.check.Error != '') {
score = '!'
@@ -102,7 +100,7 @@ export default {
},
computed: {
graphSections: function () {
graphSections() {
let score = this.check.Score
let p = Math.round(score / 5 * 100)
if (p > 100) {
@@ -125,7 +123,7 @@ export default {
]
},
scoreColor: function () {
scoreColor() {
return this.graphSections[0].color
},
}

View File

@@ -2,6 +2,7 @@ import axios from 'axios'
import dayjs from 'dayjs'
import ColorHash from 'color-hash'
import { Modal, Offcanvas } from 'bootstrap'
import { limitOptions } from "../stores/pagination";
// BootstrapElement is used to return a fake Bootstrap element
// if the ID returns nothing to prevent errors.
@@ -24,15 +25,15 @@ export default {
},
methods: {
resolve: function (u) {
resolve(u) {
return this.$router.resolve(u).href
},
searchURI: function (s) {
searchURI(s) {
return this.resolve('/search') + '?q=' + encodeURIComponent(s)
},
getFileSize: function (bytes) {
getFileSize(bytes) {
if (bytes == 0) {
return '0B'
}
@@ -40,19 +41,19 @@ export default {
return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]
},
formatNumber: function (nr) {
formatNumber(nr) {
return new Intl.NumberFormat().format(nr)
},
messageDate: function (d) {
messageDate(d) {
return dayjs(d).format('ddd, D MMM YYYY, h:mm a')
},
secondsToRelative: function (d) {
secondsToRelative(d) {
return dayjs().subtract(d, 'seconds').fromNow()
},
tagEncodeURI: function (tag) {
tagEncodeURI(tag) {
if (tag.match(/ /)) {
tag = `"${tag}"`
}
@@ -60,23 +61,37 @@ export default {
return encodeURIComponent(`tag:${tag}`)
},
getSearch: function () {
getSearch() {
if (!window.location.search) {
return false
}
const urlParams = new URLSearchParams(window.location.search)
const q = urlParams.get('q').trim()
if (q == '') {
const q = urlParams.get('q')?.trim()
if (!q) {
return false
}
return q
},
getPaginationParams() {
if (!window.location.search) {
return null
}
const urlParams = new URLSearchParams(window.location.search)
const start = parseInt(urlParams.get('start')?.trim(), 10)
const limit = parseInt(urlParams.get('limit')?.trim(), 10)
return {
start: Number.isInteger(start) && start >= 0 ? start : null,
limit: limitOptions.includes(limit) ? limit : null,
}
},
// generic modal get/set function
modal: function (id) {
let e = document.getElementById(id)
modal(id) {
const e = document.getElementById(id)
if (e) {
return Modal.getOrCreateInstance(e)
}
@@ -85,8 +100,8 @@ export default {
},
// close mobile navigation
hideNav: function () {
let e = document.getElementById('offcanvas')
hideNav() {
const e = document.getElementById('offcanvas')
if (e) {
Offcanvas.getOrCreateInstance(e).hide()
}
@@ -100,22 +115,21 @@ export default {
* @params function callback function
* @params function error callback function
*/
get: function (url, values, callback, errorCallback) {
let self = this
self.loading++
get(url, values, callback, errorCallback) {
this.loading++
axios.get(url, { params: values })
.then(callback)
.catch(function (err) {
.catch((err) => {
if (typeof errorCallback == 'function') {
return errorCallback(err)
}
self.handleError(err)
this.handleError(err)
})
.then(function () {
.then(() => {
// always executed
if (self.loading > 0) {
self.loading--
if (this.loading > 0) {
this.loading--
}
})
},
@@ -127,16 +141,15 @@ export default {
* @params array object/array values
* @params function callback function
*/
post: function (url, data, callback) {
let self = this
self.loading++
post(url, data, callback) {
this.loading++
axios.post(url, data)
.then(callback)
.catch(self.handleError)
.then(function () {
.catch(this.handleError)
.then(() => {
// always executed
if (self.loading > 0) {
self.loading--
if (this.loading > 0) {
this.loading--
}
})
},
@@ -148,16 +161,15 @@ export default {
* @params array object/array values
* @params function callback function
*/
delete: function (url, data, callback) {
let self = this
self.loading++
delete(url, data, callback) {
this.loading++
axios.delete(url, { data: data })
.then(callback)
.catch(self.handleError)
.then(function () {
.catch(this.handleError)
.then(() => {
// always executed
if (self.loading > 0) {
self.loading--
if (this.loading > 0) {
this.loading--
}
})
},
@@ -169,22 +181,21 @@ export default {
* @params array object/array values
* @params function callback function
*/
put: function (url, data, callback) {
let self = this
self.loading++
put(url, data, callback) {
this.loading++
axios.put(url, data)
.then(callback)
.catch(self.handleError)
.then(function () {
.catch(this.handleError)
.then(() => {
// always executed
if (self.loading > 0) {
self.loading--
if (this.loading > 0) {
this.loading--
}
})
},
// Ajax error message
handleError: function (error) {
handleError(error) {
// handle error
if (error.response && error.response.data) {
// The request was made and the server responded with a status code
@@ -203,7 +214,7 @@ export default {
}
},
allAttachments: function (message) {
allAttachments(message) {
let a = []
for (let i in message.Attachments) {
a.push(message.Attachments[i])
@@ -222,7 +233,7 @@ export default {
return a.ContentType.match(/^image\//)
},
attachmentIcon: function (a) {
attachmentIcon(a) {
let ext = a.FileName.split('.').pop().toLowerCase()
if (a.ContentType.match(/^image\//)) {
@@ -264,7 +275,7 @@ export default {
// Returns a hex color based on a string.
// Values are stored in an array for faster lookup / processing.
colorHash: function (s) {
colorHash(s) {
if (this.tagColorCache[s] != undefined) {
return this.tagColorCache[s]
}

View File

@@ -25,19 +25,25 @@ export default {
},
methods: {
reloadMailbox: function () {
reloadMailbox() {
pagination.start = 0
this.loadMessages()
},
loadMessages: function () {
loadMessages() {
if (!this.apiURI) {
alert('apiURL not set!')
return
}
let self = this
let params = {}
// auto-pagination changes the URL but should not fetch new messages
// when viewing page > 0 and new messages are received (inbox only)
if (!mailbox.autoPaginating) {
mailbox.autoPaginating = true // reset
return
}
const params = {}
mailbox.selected = []
params['limit'] = pagination.limit
@@ -45,7 +51,7 @@ export default {
params['start'] = pagination.start
}
self.get(this.apiURI, params, function (response) {
this.get(this.apiURI, params, (response) => {
mailbox.total = response.data.total // all messages
mailbox.unread = response.data.unread // all unread messages
mailbox.tags = response.data.tags // all tags
@@ -56,18 +62,18 @@ export default {
if (response.data.count == 0 && response.data.start > 0) {
pagination.start = 0
return self.loadMessages()
return this.loadMessages()
}
if (mailbox.lastMessage) {
window.setTimeout(() => {
let m = document.getElementById(mailbox.lastMessage)
const m = document.getElementById(mailbox.lastMessage)
if (m) {
m.focus()
// m.scrollIntoView({ behavior: 'smooth', block: 'center' })
m.scrollIntoView({ block: 'center' })
} else {
let mp = document.getElementById('message-page')
const mp = document.getElementById('message-page')
if (mp) {
mp.scrollTop = 0
}
@@ -77,7 +83,7 @@ export default {
}, 50)
} else if (!window.scrollInPlace) {
let mp = document.getElementById('message-page')
const mp = document.getElementById('message-page')
if (mp) {
mp.scrollTop = 0
}

View File

@@ -4,20 +4,21 @@ import { reactive, watch } from 'vue'
// global mailbox info
export const mailbox = reactive({
total: 0, // total number of messages in database
unread: 0, // total unread messages in database
count: 0, // total in mailbox or search
messages: [], // current messages
tags: [], // all tags
selected: [], // currently selected
connected: false, // websocket connection
searching: false, // current search, false for none
refresh: false, // to listen from MessagesMixin
total: 0, // total number of messages in database
unread: 0, // total unread messages in database
count: 0, // total in mailbox or search
messages: [], // current messages
tags: [], // all tags
selected: [], // currently selected
connected: false, // websocket connection
searching: false, // current search, false for none
refresh: false, // to listen from MessagesMixin
autoPaginating: true, // allows temporary bypass of loadMessages() via auto-pagination
notificationsSupported: false,
notificationsEnabled: false,
appInfo: {}, // application information
uiConfig: {}, // configuration for UI
lastMessage: false, // return scrolling
appInfo: {}, // application information
uiConfig: {}, // configuration for UI
lastMessage: false, // return scrolling
// settings
showTagColors: !localStorage.getItem('hideTagColors') == '1',

View File

@@ -3,6 +3,9 @@ import { reactive } from 'vue'
export const pagination = reactive({
start: 0, // pagination offset
limit: 50, // per page
defaultLimit: 50, // used to shorten URL's if current limit == defaultLimit
total: 0, // total results of current view / filter
count: 0, // number of messages currently displayed
})
export const limitOptions = [25, 50, 100, 200]

View File

@@ -9,6 +9,7 @@ import NavTags from '../components/NavTags.vue'
import Pagination from '../components/Pagination.vue'
import SearchForm from '../components/SearchForm.vue'
import { mailbox } from '../stores/mailbox'
import { pagination } from "../stores/pagination";
export default {
mixins: [CommonMixins, MessagesMixins],
@@ -29,11 +30,33 @@ export default {
}
},
watch: {
$route(to, from) {
this.loadMailbox()
}
},
mounted() {
mailbox.searching = false
this.apiURI = this.resolve(`/api/v1/messages`)
this.loadMessages()
this.loadMailbox()
},
methods: {
loadMailbox() {
const paginationParams = this.getPaginationParams()
if (paginationParams?.start) {
pagination.start = paginationParams.start
} else {
pagination.start = 0
}
if (paginationParams?.limit) {
pagination.limit = paginationParams.limit
}
this.loadMessages()
}
}
}
</script>
@@ -55,7 +78,7 @@ export default {
<i class="bi bi-list"></i>
</button>
</div>
<Pagination @loadMessages="loadMessages" :total="mailbox.total" />
<Pagination :total="mailbox.total" />
</div>
</div>

View File

@@ -41,16 +41,14 @@ export default {
},
methods: {
loadMessage: function () {
let self = this
loadMessage() {
this.message = false
let uri = self.resolve('/api/v1/message/' + this.$route.params.id)
self.get(uri, false, function (response) {
self.errorMessage = false
const uri = this.resolve('/api/v1/message/' + this.$route.params.id)
this.get(uri, false, (response) => {
this.errorMessage = false
const d = response.data
let d = response.data
if (self.wasUnread(d.ID)) {
if (this.wasUnread(d.ID)) {
mailbox.unread--
}
@@ -61,14 +59,14 @@ export default {
if (a.ContentID != '') {
d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + self.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
)
}
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
// some old email clients use the filename
d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + self.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
)
}
}
@@ -81,43 +79,43 @@ export default {
if (a.ContentID != '') {
d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + self.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
)
}
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
// some old email clients use the filename
d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + self.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
)
}
}
}
self.message = d
this.message = d
self.detectPrevNext()
this.detectPrevNext()
},
function (error) {
self.errorMessage = true
(error) => {
this.errorMessage = true
if (error.response && error.response.data) {
if (error.response.data.Error) {
self.errorMessage = error.response.data.Error
this.errorMessage = error.response.data.Error
} else {
self.errorMessage = error.response.data
this.errorMessage = error.response.data
}
} else if (error.request) {
// The request was made but no response was received
self.errorMessage = 'Error sending data to the server. Please refresh the page.'
this.errorMessage = 'Error sending data to the server. Please refresh the page.'
} else {
// Something happened in setting up the request that triggered an Error
self.errorMessage = error.message
this.errorMessage = error.message
}
})
},
// try detect whether this message was unread based on messages listing
wasUnread: function (id) {
wasUnread(id) {
for (let m in mailbox.messages) {
if (mailbox.messages[m].ID == id) {
if (!mailbox.messages[m].Read) {
@@ -129,7 +127,7 @@ export default {
}
},
detectPrevNext: function () {
detectPrevNext() {
// generate the prev/next links based on current message list
this.prevLink = false
this.nextLink = false
@@ -147,59 +145,72 @@ export default {
}
},
downloadMessageBody: function (str, ext) {
let dl = document.createElement('a')
downloadMessageBody(str, ext) {
const dl = document.createElement('a')
dl.href = "data:text/plain," + encodeURIComponent(str)
dl.target = '_blank'
dl.download = this.message.ID + '.' + ext
dl.click()
},
screenshotMessageHTML: function () {
screenshotMessageHTML() {
this.$refs.ScreenshotRef.initScreenshot()
},
// mark current message as read
markUnread: function () {
let self = this
if (!self.message) {
markUnread() {
if (!this.message) {
return false
}
let uri = self.resolve('/api/v1/messages')
self.put(uri, { 'read': false, 'ids': [self.message.ID] }, function (response) {
self.goBack()
const uri = this.resolve('/api/v1/messages')
this.put(uri, { 'read': false, 'ids': [this.message.ID] }, (response) => {
this.goBack()
})
},
deleteMessage: function () {
let self = this
let ids = [self.message.ID]
let uri = self.resolve('/api/v1/messages')
self.delete(uri, { 'ids': ids }, function () {
self.goBack()
deleteMessage() {
const ids = [this.message.ID]
const uri = this.resolve('/api/v1/messages')
this.delete(uri, { 'ids': ids }, () => {
this.goBack()
})
},
goBack: function () {
goBack() {
mailbox.lastMessage = this.$route.params.id
if (mailbox.searching) {
this.$router.push('/search?q=' + encodeURIComponent(mailbox.searching))
const p = {
q: mailbox.searching
}
if (pagination.start > 0) {
p.start = pagination.start.toString()
}
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
}
const params = new URLSearchParams(p)
this.$router.push('/search?' + params.toString())
} else {
this.$router.push('/')
const p = {}
if (pagination.start > 0) {
p.start = pagination.start.toString()
}
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
}
const params = new URLSearchParams(p)
this.$router.push('/?' + params.toString())
}
},
initReleaseModal: function () {
let self = this
self.modal('ReleaseModal').show()
window.setTimeout(function () {
window.setTimeout(function () {
// delay to allow elements to load / focus
self.$refs.ReleaseRef.initTags()
document.querySelector('#ReleaseModal input[role="combobox"]').focus()
}, 500)
}, 300)
initReleaseModal() {
this.modal('ReleaseModal').show()
window.setTimeout(() => {
// delay to allow elements to load / focus
this.$refs.ReleaseRef.initTags()
document.querySelector('#ReleaseModal input[role="combobox"]').focus()
}, 500)
},
}
}
@@ -221,7 +232,8 @@ export default {
<i class="bi bi-eye-slash"></i> <span class="d-none d-md-inline">Mark unread</span>
</button>
<button class="btn btn-outline-light me-1 me-sm-2" title="Release message"
v-if="mailbox.uiConfig.MessageRelay && mailbox.uiConfig.MessageRelay.Enabled" v-on:click="initReleaseModal">
v-if="mailbox.uiConfig.MessageRelay && mailbox.uiConfig.MessageRelay.Enabled"
v-on:click="initReleaseModal">
<i class="bi bi-send"></i> <span class="d-none d-md-inline">Release</span>
</button>
<button class="btn btn-outline-light me-1 me-sm-2" title="Delete message" v-on:click="deleteMessage">
@@ -298,8 +310,12 @@ export default {
<div class="row flex-fill" style="min-height:0">
<div class="d-none d-md-block col-xl-2 col-md-3 mh-100 position-relative"
style="overflow-y: auto; overflow-x: hidden;">
<div class="text-center badge text-bg-primary py-2 mt-2 w-100 text-truncate fw-normal"
v-if="mailbox.uiConfig.Label">
{{ mailbox.uiConfig.Label }}
</div>
<div class="list-group my-2">
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
<button @click="goBack()" class="list-group-item list-group-item-action">
<i class="bi bi-arrow-return-left me-1"></i>
<span class="ms-1">Return</span>
@@ -341,6 +357,7 @@ export default {
<AboutMailpit modals />
<AjaxLoader :loading="loading" />
<Release v-if="mailbox.uiConfig.MessageRelay && message" ref="ReleaseRef" :message="message" @delete="deleteMessage" />
<Release v-if="mailbox.uiConfig.MessageRelay && message" ref="ReleaseRef" :message="message"
@delete="deleteMessage" />
<Screenshot v-if="message" ref="ScreenshotRef" :message="message" />
</template>

View File

@@ -33,18 +33,18 @@ export default {
watch: {
$route(to, from) {
this.doSearch(true)
this.doSearch()
}
},
mounted() {
mailbox.searching = this.getSearch()
this.doSearch(false)
this.doSearch()
},
methods: {
doSearch: function (resetPagination) {
let s = this.getSearch()
doSearch() {
const s = this.getSearch()
if (!s) {
mailbox.searching = false
@@ -54,10 +54,6 @@ export default {
mailbox.searching = s
if (resetPagination) {
pagination.start = 0
}
this.apiURI = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) {
this.apiURI += '&tz=' + encodeURIComponent(mailbox.timeZone)
@@ -86,7 +82,7 @@ export default {
<i class="bi bi-list"></i>
</button>
</div>
<Pagination @loadMessages="loadMessages" :total="mailbox.count" />
<Pagination :total="mailbox.count" />
</div>
</div>

View File

@@ -703,6 +703,79 @@
}
}
},
"/api/v1/tags/{tag}": {
"put": {
"description": "Renames a tag.",
"produces": [
"text/plain"
],
"schemes": [
"http",
"https"
],
"tags": [
"tags"
],
"summary": "Rename a tag",
"operationId": "RenameTag",
"parameters": [
{
"name": "Body",
"in": "body",
"schema": {
"$ref": "#/definitions/renameTagRequestBody"
}
},
{
"type": "string",
"description": "The url-encoded tag name to rename",
"name": "tag",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/OKResponse"
},
"default": {
"$ref": "#/responses/ErrorResponse"
}
}
},
"delete": {
"description": "Deletes a tag. This will not delete any messages with this tag.",
"produces": [
"text/plain"
],
"schemes": [
"http",
"https"
],
"tags": [
"tags"
],
"summary": "Delete a tag",
"operationId": "DeleteTag",
"parameters": [
{
"type": "string",
"description": "The url-encoded tag name to delete",
"name": "tag",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/OKResponse"
},
"default": {
"$ref": "#/responses/ErrorResponse"
}
}
}
},
"/api/v1/webui": {
"get": {
"description": "Returns configuration settings for the web UI.\nIntended for web UI only!",
@@ -1635,6 +1708,10 @@
"description": "Whether messages with duplicate IDs are ignored",
"type": "boolean"
},
"Label": {
"description": "Optional label to identify this Mailpit instance",
"type": "string"
},
"MessageRelay": {
"description": "Message Relay information",
"type": "object",
@@ -1643,6 +1720,10 @@
"description": "Only allow relaying to these recipients (regex)",
"type": "string"
},
"BlockedRecipients": {
"description": "Block relaying to these recipients (regex)",
"type": "string"
},
"Enabled": {
"description": "Whether message relaying (release) is enabled",
"type": "boolean"
@@ -1686,6 +1767,21 @@
},
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
},
"renameTagRequestBody": {
"description": "Rename tag request",
"type": "object",
"required": [
"Name"
],
"properties": {
"Name": {
"description": "New name",
"type": "string",
"example": "New name"
}
},
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
},
"setReadStatusRequestBody": {
"description": "Set read status request",
"type": "object",