mirror of
https://github.com/axllent/mailpit.git
synced 2026-03-09 14:27:01 +00:00
Compare commits
184 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
009d02816f | ||
|
|
5131b6a0cc | ||
|
|
d2070e1ee1 | ||
|
|
405babda7b | ||
|
|
882adeebe3 | ||
|
|
f8efda0149 | ||
|
|
d61304a854 | ||
|
|
4ff9fdf298 | ||
|
|
51e29ba90a | ||
|
|
9ab289a6c9 | ||
|
|
2c945be5b9 | ||
|
|
f9a185da46 | ||
|
|
73a993492e | ||
|
|
a56fd1f53d | ||
|
|
073ddd33d5 | ||
|
|
f142893d58 | ||
|
|
bd026bef8c | ||
|
|
26e8706eb4 | ||
|
|
ff8cd229ca | ||
|
|
2c326acf08 | ||
|
|
1aed5fda5a | ||
|
|
9a4982e646 | ||
|
|
a64950ddea | ||
|
|
7f4cd90c03 | ||
|
|
56f1138f8e | ||
|
|
bd5c450294 | ||
|
|
54a72e8e1e | ||
|
|
069967f502 | ||
|
|
4ee3ba4753 | ||
|
|
84e46e6604 | ||
|
|
2048f15bbf | ||
|
|
93761b6f53 | ||
|
|
2a0853d21a | ||
|
|
dc1a16ed5c | ||
|
|
f95147fd83 | ||
|
|
c84bfc3330 | ||
|
|
b22eccd88c | ||
|
|
1c8f0bf136 | ||
|
|
48195b004e | ||
|
|
32185e3abe | ||
|
|
be1d2bcb28 | ||
|
|
259d71122b | ||
|
|
b37a24fdcf | ||
|
|
f598c9adbb | ||
|
|
aaa873ed68 | ||
|
|
fb8b24cc28 | ||
|
|
7d55e20e85 | ||
|
|
e98109a238 | ||
|
|
3cec8bfab8 | ||
|
|
4f2324a367 | ||
|
|
ac60ed62ae | ||
|
|
65327b975b | ||
|
|
ba42cac2ad | ||
|
|
5fc025b1a5 | ||
|
|
48bef8d7ac | ||
|
|
37ea30fcdb | ||
|
|
8f1b804b2a | ||
|
|
f8a6bd7d5e | ||
|
|
047c658157 | ||
|
|
a060abd5fe | ||
|
|
a21808df65 | ||
|
|
1e4fc9f003 | ||
|
|
3fdbcaff8a | ||
|
|
71820dc124 | ||
|
|
81e98d1376 | ||
|
|
27c36f52b2 | ||
|
|
325394876d | ||
|
|
5a54994a5d | ||
|
|
d48b5e8674 | ||
|
|
3f3da220cf | ||
|
|
9040e04edf | ||
|
|
6baf13b25b | ||
|
|
4716c18d5f | ||
|
|
22693f727f | ||
|
|
476843d9f3 | ||
|
|
a1cb0af639 | ||
|
|
54e0c32948 | ||
|
|
9670183d0f | ||
|
|
05da2a76f4 | ||
|
|
f16289078e | ||
|
|
5580967c78 | ||
|
|
eeb2c03424 | ||
|
|
0127b9a1f2 | ||
|
|
a078c318e8 | ||
|
|
9e881ea868 | ||
|
|
41c957b807 | ||
|
|
ea0b5f66f7 | ||
|
|
1f7a60452e | ||
|
|
14943324e8 | ||
|
|
b05c6fbf60 | ||
|
|
21a6f798d1 | ||
|
|
9014376e80 | ||
|
|
609b2a64ea | ||
|
|
eb120a231b | ||
|
|
fd03926260 | ||
|
|
6947c2a621 | ||
|
|
406fe56fc6 | ||
|
|
13a418370f | ||
|
|
80a2ab68c2 | ||
|
|
1d9c12b657 | ||
|
|
a1b1e97f75 | ||
|
|
61e8cad507 | ||
|
|
1f0f9efa7a | ||
|
|
f5f2371839 | ||
|
|
3fcbdb3273 | ||
|
|
52d8806c01 | ||
|
|
b941015632 | ||
|
|
0c377b9616 | ||
|
|
0dca8df29c | ||
|
|
c7e0455479 | ||
|
|
19645db2de | ||
|
|
6373a33bff | ||
|
|
9a3d0ca337 | ||
|
|
4193489b9e | ||
|
|
eac0b9d5df | ||
|
|
e60fefb33b | ||
|
|
0bc8dcc161 | ||
|
|
99c5c1a120 | ||
|
|
33e367d706 | ||
|
|
5e5b855a3d | ||
|
|
e15a8fecc5 | ||
|
|
eb0ef8baff | ||
|
|
a155b395db | ||
|
|
8de2c5ec81 | ||
|
|
7a55e4d0e2 | ||
|
|
f7f200c6fe | ||
|
|
1bd6794b2d | ||
|
|
7204964cf8 | ||
|
|
a4b081f9b9 | ||
|
|
1529e424f8 | ||
|
|
48045ec0aa | ||
|
|
545162e6fc | ||
|
|
d2f586c133 | ||
|
|
2cf0b50d1b | ||
|
|
70baf12adb | ||
|
|
710f093561 | ||
|
|
b7ad94211b | ||
|
|
7991c49312 | ||
|
|
7773c6b04c | ||
|
|
a32237e14f | ||
|
|
ce7dcce61c | ||
|
|
83c94c879a | ||
|
|
029db4bc00 | ||
|
|
b595af6b72 | ||
|
|
79e1f9d773 | ||
|
|
28a8502a65 | ||
|
|
7105450cc7 | ||
|
|
8a6d71ed9c | ||
|
|
aa3f94457c | ||
|
|
e87b98b73b | ||
|
|
21eef69a60 | ||
|
|
1fb869fb5e | ||
|
|
31390e4b82 | ||
|
|
3974fdfbaf | ||
|
|
9909fd969c | ||
|
|
abd1f0b008 | ||
|
|
0dbbb821eb | ||
|
|
262be51c9b | ||
|
|
5dee4cc763 | ||
|
|
f89fa46902 | ||
|
|
c25dee57c3 | ||
|
|
e192d5efd2 | ||
|
|
0de93c7868 | ||
|
|
3e28acde6a | ||
|
|
ae05840571 | ||
|
|
4269192f32 | ||
|
|
35fb3d1790 | ||
|
|
0ec2f8bc61 | ||
|
|
ed4618a1f3 | ||
|
|
09f50f64fd | ||
|
|
f87ec396c9 | ||
|
|
3e37293c99 | ||
|
|
7147032c6b | ||
|
|
3c36951113 | ||
|
|
86e8a126ca | ||
|
|
7f586e15cf | ||
|
|
2a5559f5f0 | ||
|
|
ead3fad1dd | ||
|
|
abd546133e | ||
|
|
fae0384dfe | ||
|
|
aa1a5a0954 | ||
|
|
c81ea54c87 | ||
|
|
ebf7bb6348 | ||
|
|
ba0e40fc7f |
@@ -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:
|
||||
|
||||
17
.github/workflows/build-docker-edge.yml
vendored
17
.github/workflows/build-docker-edge.yml
vendored
@@ -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
|
||||
|
||||
15
.github/workflows/build-docker.yml
vendored
15
.github/workflows/build-docker.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/close-stale-issues.yml
vendored
2
.github/workflows/close-stale-issues.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
with:
|
||||
days-before-issue-stale: 7
|
||||
days-before-issue-close: 3
|
||||
exempt-issue-labels: "enhancement,bug,javascript,docker"
|
||||
exempt-issue-labels: "enhancement,bug,awaiting feedback"
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue has been marked as stale because it has been open for 7 days with no activity."
|
||||
close-issue-message: "This issue was closed because there has been no activity since being marked as stale."
|
||||
|
||||
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
@@ -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 ./internal/linkcheck -v
|
||||
- run: go test -p 1 ./internal/storage ./internal/html2text -bench=.
|
||||
|
||||
# build the assets
|
||||
@@ -44,6 +44,6 @@ jobs:
|
||||
# validate the swagger file
|
||||
- name: Validate OpenAPI definition
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
uses: char0n/swagger-editor-validate@v1
|
||||
uses: swaggerexpert/swagger-editor-validate@v1
|
||||
with:
|
||||
definition-file: server/ui/api/v1/swagger.json
|
||||
|
||||
740
CHANGELOG.md
740
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
FROM golang:alpine as builder
|
||||
FROM golang:alpine AS builder
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
|
||||
19
SECURITY.md
Normal file
19
SECURITY.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Reporting security vulnerabilities
|
||||
|
||||
Your efforts to responsibly disclose your findings are appreciated.
|
||||
|
||||
** **Please do _not_ report security vulnerabilities through public GitHub issues.** **
|
||||
|
||||
If you believe you have found a **security vulnerability**, then please report it to security@axllent.org so
|
||||
your findings can be investigated, and if confirmed, fixed and released in a timely manner.
|
||||
|
||||
Your report should include:
|
||||
|
||||
- Mailpit version
|
||||
- A vulnerability description
|
||||
- Reproduction steps (if applicable)
|
||||
- Any other details you think are likely to be important
|
||||
|
||||
You should receive an initial acknowledgement within 24 hours in most cases, and will kept updated throughout the process.
|
||||
|
||||
With your consent, your contributions will be publicly acknowledged.
|
||||
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
@@ -13,8 +14,6 @@ import (
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
sendmail "github.com/axllent/mailpit/sendmail/cmd"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -36,7 +35,6 @@ The --recent flag will only consider files with a modification date within the l
|
||||
var count int
|
||||
var total int
|
||||
var per100start = time.Now()
|
||||
p := message.NewPrinter(language.English)
|
||||
|
||||
for _, a := range args {
|
||||
err := filepath.Walk(a,
|
||||
@@ -117,8 +115,7 @@ The --recent flag will only consider files with a modification date within the l
|
||||
count++
|
||||
total++
|
||||
if count%100 == 0 {
|
||||
formatted := p.Sprintf("%d", total)
|
||||
logger.Log().Infof("[%s] 100 messages in %s", formatted, time.Since(per100start))
|
||||
logger.Log().Infof("[%s] 100 messages in %s", format(total), time.Since(per100start))
|
||||
|
||||
per100start = time.Now()
|
||||
}
|
||||
@@ -149,3 +146,29 @@ func isFile(path string) bool {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Format a an integer 10000 => 10,000
|
||||
func format(n int) string {
|
||||
in := fmt.Sprintf("%d", n)
|
||||
numOfDigits := len(in)
|
||||
if n < 0 {
|
||||
numOfDigits-- // First character is the - sign (not a digit)
|
||||
}
|
||||
numOfCommas := (numOfDigits - 1) / 3
|
||||
|
||||
out := make([]byte, len(in)+numOfCommas)
|
||||
if n < 0 {
|
||||
in, out[0] = in[1:], '-'
|
||||
}
|
||||
|
||||
for i, j, k := len(in)-1, len(out)-1, 0; ; i, j = i-1, j-1 {
|
||||
out[j] = in[i]
|
||||
if i == 0 {
|
||||
return string(out)
|
||||
}
|
||||
if k++; k == 3 {
|
||||
j, k = j-1, 0
|
||||
out[j] = ','
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
cmd/root.go
13
cmd/root.go
@@ -82,8 +82,10 @@ 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().StringVar(&config.MaxAge, "max-age", config.MaxAge, "Max age of messages in either (h)ours or (d)ays (eg: 3d)")
|
||||
rootCmd.Flags().BoolVar(&config.UseMessageDates, "use-message-dates", config.UseMessageDates, "Use message dates as the received dates")
|
||||
rootCmd.Flags().BoolVar(&config.IgnoreDuplicateIDs, "ignore-duplicate-ids", config.IgnoreDuplicateIDs, "Ignore duplicate messages (by Message-Id)")
|
||||
rootCmd.Flags().StringVar(&logger.LogFile, "log-file", logger.LogFile, "Log output to file instead of stdout")
|
||||
@@ -130,6 +132,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,9 +175,14 @@ 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"))
|
||||
}
|
||||
if len(os.Getenv("MP_MAX_AGE")) > 0 {
|
||||
config.MaxAge = os.Getenv("MP_MAX_AGE")
|
||||
}
|
||||
if getEnabledFromEnv("MP_USE_MESSAGE_DATES") {
|
||||
config.UseMessageDates = true
|
||||
}
|
||||
@@ -271,6 +279,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 +296,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 {
|
||||
@@ -295,6 +305,9 @@ func initConfigFromEnv() {
|
||||
if len(os.Getenv("MP_WEBHOOK_LIMIT")) > 0 {
|
||||
webhook.RateLimit, _ = strconv.Atoi(os.Getenv("MP_WEBHOOK_LIMIT"))
|
||||
}
|
||||
|
||||
// Demo mode
|
||||
config.DemoMode = getEnabledFromEnv("MP_DEMO_MODE")
|
||||
}
|
||||
|
||||
// load deprecated settings from environment and warn
|
||||
|
||||
116
config/config.go
116
config/config.go
@@ -10,11 +10,13 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -30,11 +32,22 @@ var (
|
||||
|
||||
// TenantID is an optional prefix to be applied to all database tables,
|
||||
// allowing multiple isolated instances of Mailpit to share a database.
|
||||
TenantID = ""
|
||||
TenantID string
|
||||
|
||||
// Label to identify this Mailpit instance (optional).
|
||||
// This gets applied to web UI, SMTP and optional POP3 server.
|
||||
Label string
|
||||
|
||||
// MaxMessages is the maximum number of messages a mailbox can have (auto-pruned every minute)
|
||||
MaxMessages = 500
|
||||
|
||||
// MaxAge is the maximum age of messages (auto-pruned every hour).
|
||||
// Value can be either <int>h for hours or <int>d for days
|
||||
MaxAge string
|
||||
|
||||
// MaxAgeInHours is the maximum age of messages in hours, set with parseMaxAge() using MaxAge value
|
||||
MaxAgeInHours int
|
||||
|
||||
// UseMessageDates sets the Created date using the message date, not the delivered date
|
||||
UseMessageDates bool
|
||||
|
||||
@@ -97,6 +110,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
|
||||
|
||||
@@ -161,6 +178,9 @@ var (
|
||||
|
||||
// DisableHTMLCheck DEPRECATED 2024/04/13 - kept here to display console warning only
|
||||
DisableHTMLCheck = false
|
||||
|
||||
// DemoMode disables SMTP relay, link checking & HTTP send functionality
|
||||
DemoMode = false
|
||||
)
|
||||
|
||||
// AutoTag struct for auto-tagging
|
||||
@@ -182,6 +202,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"`
|
||||
}
|
||||
@@ -193,6 +216,9 @@ func VerifyConfig() error {
|
||||
cssFontRestriction = "'self'"
|
||||
}
|
||||
|
||||
// The default Content Security Policy is updates on every application page load to replace script-src 'self'
|
||||
// with a random nonce ID to prevent XSS. This applies to the Mailpit app & API.
|
||||
// See server.middleWareFunc()
|
||||
ContentSecurityPolicy = fmt.Sprintf("default-src 'self'; script-src 'self'; style-src %s 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src %s data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';",
|
||||
cssFontRestriction, cssFontRestriction,
|
||||
)
|
||||
@@ -201,14 +227,15 @@ func VerifyConfig() error {
|
||||
Database = filepath.Join(Database, "mailpit.db")
|
||||
}
|
||||
|
||||
TenantID = strings.TrimSpace(TenantID)
|
||||
Label = tools.Normalize(Label)
|
||||
|
||||
if err := parseMaxAge(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
TenantID = DBTenantID(TenantID)
|
||||
if TenantID != "" {
|
||||
logger.Log().Infof("[db] using tenant \"%s\"", TenantID)
|
||||
re := regexp.MustCompile(`[^a-zA-Z0-9\_]`)
|
||||
TenantID = re.ReplaceAllString(TenantID, "_")
|
||||
if !strings.HasSuffix(TenantID, "_") {
|
||||
TenantID = TenantID + "_"
|
||||
}
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`.*:\d+$`)
|
||||
@@ -383,7 +410,7 @@ func VerifyConfig() error {
|
||||
}
|
||||
}
|
||||
|
||||
// load tag filters
|
||||
// load tag filters & options
|
||||
TagFilters = []autoTag{}
|
||||
if err := loadTagsFromArgs(CLITagsArg); err != nil {
|
||||
return err
|
||||
@@ -391,6 +418,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 +449,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)
|
||||
}
|
||||
@@ -435,6 +465,45 @@ func VerifyConfig() error {
|
||||
logger.Log().Warnf("[relay] auto-relaying all new messages via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
|
||||
}
|
||||
|
||||
if DemoMode {
|
||||
MaxMessages = 1000
|
||||
// this deserves a warning
|
||||
logger.Log().Info("demo mode enabled")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the --max-age value (if set)
|
||||
func parseMaxAge() error {
|
||||
if MaxAge == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`^\d+(h|d)$`)
|
||||
if !re.MatchString(MaxAge) {
|
||||
return fmt.Errorf("max-age must be either <int>h for hours or <int>d for days: %s", MaxAge)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(MaxAge, "h") {
|
||||
hours, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "h"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
MaxAgeInHours = hours
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
days, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "d"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[db] auto-deleting messages older than %s", MaxAge)
|
||||
|
||||
MaxAgeInHours = days * 24
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -511,14 +580,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
|
||||
@@ -549,3 +627,17 @@ func isValidURL(s string) bool {
|
||||
|
||||
return strings.HasPrefix(u.Scheme, "http")
|
||||
}
|
||||
|
||||
// DBTenantID converts a tenant ID to a DB-friendly value if set
|
||||
func DBTenantID(s string) string {
|
||||
s = tools.Normalize(s)
|
||||
if s != "" {
|
||||
re := regexp.MustCompile(`[^a-zA-Z0-9\_]`)
|
||||
s = re.ReplaceAllString(s, "_")
|
||||
if !strings.HasSuffix(s, "_") {
|
||||
s = s + "_"
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
43
go.mod
43
go.mod
@@ -1,34 +1,34 @@
|
||||
module github.com/axllent/mailpit
|
||||
|
||||
go 1.21.0
|
||||
go 1.23
|
||||
|
||||
toolchain go1.22.1
|
||||
toolchain go1.23.2
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.9.2
|
||||
github.com/PuerkitoBio/goquery v1.10.0
|
||||
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-20240930133441-72d49d9543d8
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
github.com/jhillyerd/enmime v1.2.0
|
||||
github.com/klauspost/compress v1.17.8
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jhillyerd/enmime v1.3.0
|
||||
github.com/klauspost/compress v1.17.11
|
||||
github.com/kovidgoyal/imaging v1.6.3
|
||||
github.com/leporo/sqlf v1.4.0
|
||||
github.com/lithammer/shortuuid/v4 v4.0.0
|
||||
github.com/mhale/smtpd v0.8.2
|
||||
github.com/mhale/smtpd v0.8.3
|
||||
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e
|
||||
github.com/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418
|
||||
github.com/rqlite/gorqlite v0.0.0-20241013203532-4385768ae85d
|
||||
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.24.0
|
||||
golang.org/x/text v0.14.0
|
||||
golang.org/x/time v0.5.0
|
||||
github.com/vanng822/go-premailer v1.22.0
|
||||
golang.org/x/net v0.30.0
|
||||
golang.org/x/text v0.19.0
|
||||
golang.org/x/time v0.7.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.29.8
|
||||
modernc.org/sqlite v1.33.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -44,7 +44,7 @@ require (
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
|
||||
github.com/kr/pretty v0.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
@@ -54,12 +54,13 @@ 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.22.0 // indirect
|
||||
golang.org/x/image v0.15.0 // indirect
|
||||
golang.org/x/sys v0.19.0 // indirect
|
||||
golang.org/x/crypto v0.28.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
|
||||
golang.org/x/image v0.21.0 // indirect
|
||||
golang.org/x/sys v0.26.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.5 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 // indirect
|
||||
modernc.org/libc v1.61.0 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
|
||||
142
go.sum
142
go.sum
@@ -1,8 +1,8 @@
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
|
||||
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
|
||||
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
|
||||
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
||||
github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
|
||||
github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
|
||||
@@ -11,20 +11,21 @@ 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=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
|
||||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||
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-20240930133441-72d49d9543d8 h1:4txT5G2kqVAKMjzidIabL/8KqjIK71yj30YOeuxLn10=
|
||||
github.com/gomarkdown/markdown v0.0.0-20240930133441-72d49d9543d8/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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,18 +35,18 @@ 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=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
|
||||
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/jhillyerd/enmime v1.3.0 h1:LV5kzfLidiOr8qRGIpYYmUZCnhrPbcFAnAFUnWn99rw=
|
||||
github.com/jhillyerd/enmime v1.3.0/go.mod h1:6c6jg5HdRRV2FtvVL69LjiX1M8oE0xDX9VEhV3oy4gs=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
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=
|
||||
@@ -63,13 +64,12 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mhale/smtpd v0.8.2 h1:rHKOMHeFoDvcq8Na9ErCbNcjlWTSyGtznOmJpWsOzuc=
|
||||
github.com/mhale/smtpd v0.8.2/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
|
||||
github.com/mhale/smtpd v0.8.3 h1:8j8YNXajksoSLZja3HdwvYVZPuJSqAxFsib3adzRRt8=
|
||||
github.com/mhale/smtpd v0.8.3/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
@@ -90,14 +90,14 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418 h1:gYUQqzapdN4PQF5j0zDFI9ANQVAVFoJivNp5bTZEZMo=
|
||||
github.com/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
|
||||
github.com/rqlite/gorqlite v0.0.0-20241013203532-4385768ae85d h1:c88ius/WcN19inn14R+X2EQCFjjAu92txgdxNNnGxDI=
|
||||
github.com/rqlite/gorqlite v0.0.0-20241013203532-4385768ae85d/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
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=
|
||||
@@ -105,85 +105,117 @@ github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02n
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tg123/go-htpasswd v1.2.2 h1:tmNccDsQ+wYsoRfiONzIhDm5OkVHQzN3w4FOBAlN6BY=
|
||||
github.com/tg123/go-htpasswd v1.2.2/go.mod h1:FcIrK0J+6zptgVwK1JDlqyajW/1B4PtuJ/FLWl7nx8A=
|
||||
github.com/unrolled/render v1.0.3/go.mod h1:gN9T0NhL4Bfbwu8ann7Ry/TGHYfosul+J0obPf6NBdM=
|
||||
github.com/unrolled/render v1.7.0/go.mod h1:LwQSeDhjml8NLjIO9GJO1/1qpFJxtfVIpzxXKjfVkoI=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8=
|
||||
github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
|
||||
github.com/vanng822/go-premailer v1.21.0 h1:qIwX4urphNPO3xa60MGqowmyjzzMtFacJPKNrt1UWFU=
|
||||
github.com/vanng822/go-premailer v1.21.0/go.mod h1:6Y3H2NzNmK3sFBNgR1ENdfV9hzG8hMzrA1nL/XBbbP4=
|
||||
github.com/vanng822/go-premailer v1.22.0 h1:5gG92q3nG3BwcfUUDzrSDbYDbpwYC/lri4nba+vhdJQ=
|
||||
github.com/vanng822/go-premailer v1.22.0/go.mod h1:K7DxRBW6AxdZUTqmW9jU6041CtfAWiP9uSXm2WmMB1k=
|
||||
github.com/vanng822/r2router v0.0.0-20150523112421-1023140a4f30/go.mod h1:1BVq8p2jVr55Ost2PkZWDrG86PiJ/0lxqcXoAcGxvWU=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
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.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
|
||||
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
|
||||
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
|
||||
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
|
||||
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.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
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=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
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.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
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.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
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.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.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=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.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.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
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=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
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 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
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/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
||||
golang.org/x/time v0.7.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.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
||||
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
||||
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 +225,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.0 h1:D/gLKtcztomvWbsbvBKo3leKQv+86f+DdqEZBBXhnag=
|
||||
modernc.org/cc/v4 v4.21.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.17.3 h1:t2CQci84jnxKw3GGnHvjGKjiNZeZqyQx/023spkk4hU=
|
||||
modernc.org/ccgo/v4 v4.17.3/go.mod h1:1FCbAtWYJoKuc+AviS+dH+vGNtYmFJqBeRWjmnDWsIg=
|
||||
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.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
|
||||
modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0=
|
||||
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.5 h1:ZzeUd0dIc/sUtoPTCYIrgypkuzoGzNu6kbEWj2VuEmk=
|
||||
modernc.org/libc v1.50.5/go.mod h1:rhzrUx5oePTSTIzBgM0mTftwWHK8tiT9aNFUt1mldl0=
|
||||
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
|
||||
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 h1:IYXPPTTjjoSHvUClZIYexDiO7g+4x+XveKT4gCIAwiY=
|
||||
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
|
||||
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
|
||||
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 +245,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.8 h1:nGKglNx9K5v0As+zF0/Gcl1kMkmaU1XynYyq92PbsC8=
|
||||
modernc.org/sqlite v1.29.8/go.mod h1:lQPm27iqa4UNZpmr4Aor0MH0HkCLbt1huYDfWylLZFk=
|
||||
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
|
||||
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||
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=
|
||||
|
||||
@@ -78,5 +78,6 @@ func clean(text string) string {
|
||||
}, text)
|
||||
|
||||
text = re.ReplaceAllString(text, " ")
|
||||
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"api_version":"1.0.4",
|
||||
"last_update_date":"2024-04-19 09:12:53 +0000",
|
||||
"last_update_date":"2024-10-09 08:12:03 +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":[
|
||||
{
|
||||
@@ -691,6 +691,22 @@
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-comments",
|
||||
"title":"CSS comments",
|
||||
"description":"Adds explanatory notes to the code or to prevent the browser from interpreting specific parts of the style sheet",
|
||||
"url":"https://www.caniemail.com/features/css-comments/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"last_test_date":"2024-04-25",
|
||||
"test_url":"https://www.caniemail.com/tests/css-comments.html",
|
||||
"test_results_url":"https://testi.at/proj/n4ayign05k6cozot6",
|
||||
"stats":{"apple-mail":{"macos":{"2024-04":"y"},"ios":{"2024-04":"y"}},"gmail":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"a #2"},"mobile-webmail":{"2024-04":"a #4"}},"orange":{"desktop-webmail":{"2024-08":"n #6"},"ios":{"2024-08":"n #6"},"android":{"2024-08":"n #6"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-04":"y"},"macos":{"2024-04":"y"},"outlook-com":{"2024-04":"y","2024-01":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"yahoo":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"a #1"},"android":{"2024-04":"a #1"}},"aol":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"a #1"},"android":{"2024-04":"a #1"}},"samsung-email":{"android":{"2024-04":"y"}},"sfr":{"desktop-webmail":{"2024-08":"a #5"},"ios":{"2024-08":"a #5"},"android":{"2024-08":"a #5"}},"thunderbird":{"macos":{"2024-04":"y"}},"protonmail":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"a #3"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-08":"a #5"}},"gmx":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Buggy. The first <head> in the HTML is removed, so comment needs to be in the `<style>` tag of a second `<head>` element.","2":"Partial. `<style>` tag not supported with non-google account. Comment inside `style:` attribute works.","3":"Partial. Comment inside `<style>` tag works. Comment inside `style` attribute strips the whole attribute.","4":"Partial. `<style>` tag not supported. Comment inside `style:` attribute works.","5":"Partial. Comment inside `style` attribute works.","6":"Not supported. The entire rule is removed within a `<style> element. The entire inline `style` attribute is removed."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-conic-gradient",
|
||||
"title":"conic-gradient()",
|
||||
@@ -787,6 +803,22 @@
|
||||
"notes_by_num":{"1":"Partial. `flex`, `grid`, `flow-root`, `contents`, `inline flow-root`, `inline flex`, `inline grid`, `initial`, `revert`, `unset` are not supported with non Google accounts.","2":"Partial. `inline-flex`, `inline-grid`, `flex`, `grid`, `flow-root`, `contents`, `inline flow-root`, `inline flex`, `inline grid`, `initial`, `revert`, `unset` values are not supported.","3":"Buggy. Only the first value is kept with the two-value syntax.","4":"Buggy. `display:none` does not inherit to inner tables.","5":"Partial. Only supports `display:none` (but not on `<img>`).","6":"Partial. `flow-root`, `inline-flex`, `inline-grid`, `inline flow`, `contents`, `revert` are not supported.","7":"Partial. Two-value syntax are combined into a single one with a dash."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-empty-cells",
|
||||
"title":"empty-cells",
|
||||
"description":"Sets whether borders and backgrounds appear around `<table>` cells that have no visible content.",
|
||||
"url":"https://www.caniemail.com/features/css-empty-cells/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":"blank",
|
||||
"last_test_date":"2024-08-23",
|
||||
"test_url":"https://www.caniemail.com/tests/css-empty-cells.html",
|
||||
"test_results_url":"https://testi.at/proj/kgl7t57xs8jxueze0v8",
|
||||
"stats":{"apple-mail":{"macos":{"2024-08":"y"},"ios":{"2024-08":"y"}},"gmail":{"desktop-webmail":{"2024-08":"y"},"ios":{"2024-08":"y"},"android":{"2024-08":"y"},"mobile-webmail":{"2024-08":"u"}},"orange":{"desktop-webmail":{"2024-08":"u"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-08":"n"},"macos":{"2024-08":"y"},"outlook-com":{"2024-08":"y"},"ios":{"2024-08":"y"},"android":{"2024-08":"y"}},"yahoo":{"desktop-webmail":{"2024-08":"y"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"}},"aol":{"desktop-webmail":{"2024-08":"y"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"}},"samsung-email":{"android":{"2024-08":"y"}},"sfr":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"protonmail":{"desktop-webmail":{"2024-08":"u"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"hey":{"desktop-webmail":{"2024-08":"u"}},"mail-ru":{"desktop-webmail":{"2024-08":"n"}},"fastmail":{"desktop-webmail":{"2024-08":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-filter",
|
||||
"title":"filter",
|
||||
@@ -843,12 +875,12 @@
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"last_test_date":"2019-02-28",
|
||||
"last_test_date":"2024-05-08",
|
||||
"test_url":"https://www.caniemail.com/tests/css-box-model.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/pyPQFHSYLFrhbRShalju0B2fYNwUgLuyKTLx4MLqiw5mE/list",
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2007":"n #1","2010":"n #1","2013":"n #1","2016":"n #1","2019":"n #1"},"windows-mail":{"2019-02":"n #1"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"n","2023-01":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"test_results_url":"https://testi.at/proj/gyjkc98dtyzxfd3bhz",
|
||||
"stats":{"apple-mail":{"macos":{"11.7":"a #2","12.4":"y"},"ios":{"14":"a #2","15":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y","2024-05":"a #2"},"android":{"2019-02":"y","2024-05":"a #2"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2007":"n #1","2010":"n #1","2013":"n #1","2016":"n #1","2019":"n #1"},"windows-mail":{"2019-02":"n #1"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y","2024-05":"a #2"},"ios":{"2019-02":"y","2024-05":"a #2"},"android":{"2019-02":"n","2023-01":"y","2024-05":"a #2"}},"aol":{"desktop-webmail":{"2019-02":"y","2024-05":"a #2"},"ios":{"2019-02":"y","2024-05":"a #2"},"android":{"2019-02":"y","2024-05":"a #2"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y","2024-05":"a #2"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y","2024-05":"a #2"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Not supported. `table` and `img` elements can use an `align` attribute to get a similar effect."}
|
||||
"notes_by_num":{"1":"Not supported. `table` and `img` elements can use an `align` attribute to get a similar effect.","2":"Partial. Logical property values `inline-start` and `inline-end` are not supported."}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -947,6 +979,22 @@
|
||||
"notes_by_num":{"1":"Partial. Not supported with non Google accounts."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-function-light-dark",
|
||||
"title":"light-dark()",
|
||||
"description":"Enables setting two colors (one for light and the other for dark mode) for a property.",
|
||||
"url":"https://www.caniemail.com/features/css-function-light-dark/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":"dark, light",
|
||||
"last_test_date":"2024-08-14",
|
||||
"test_url":"https://www.caniemail.com/tests/css-function-light-dark.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/Lai13xyIE95H6jo1BBs6ay0f3RvJdPL344S3j3M7FbeU4/list",
|
||||
"stats":{"apple-mail":{"macos":{"16.0":"y #1"},"ios":{"17.5.1":"y"}},"gmail":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"},"mobile-webmail":{"2024-08":"n"}},"orange":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2024-08":"n"},"macos":{"16.88":"n"},"outlook-com":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"u"}},"samsung-email":{"android":{"6.0":"u"}},"sfr":{"desktop-webmail":{"2024-08":"a #2"},"ios":{"2024-08":"a #1 #2"},"android":{"2024-08":"u"}},"thunderbird":{"macos":{"115.10.1":"n","128.1.0":"y"}},"aol":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"u"}},"yahoo":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"u"}},"protonmail":{"desktop-webmail":{"2024-08":"a #2"},"ios":{"2024-08":"y #1"},"android":{"2024-08":"u"}},"hey":{"desktop-webmail":{"2024-08":"a #2"}},"mail-ru":{"desktop-webmail":{"2024-08":"a #2"}},"fastmail":{"desktop-webmail":{"2024-08":"u"}},"laposte":{"desktop-webmail":{"2024-08":"a #2"}},"free-fr":{"desktop-webmail":{"2024-08":"n"}},"t-online-de":{"desktop-webmail":{"2024-08":"a #2"}},"gmx":{"desktop-webmail":{"2024-08":"n"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Only supported if you’ve updated your OS with Safari 17.5 or later.","2":"Buggy. The function is supported but the color stays light even in dark mode."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-function-max",
|
||||
"title":"max()",
|
||||
@@ -1027,6 +1075,38 @@
|
||||
"notes_by_num":{"1":"Buggy. Replaces `height` by `min-height`.","2":"Partial. Not supported on `<body>`, `<span>`, `<div>` or `<p>` elements."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-hyphenate-character",
|
||||
"title":"hyphenate-character",
|
||||
"description":"Sets the character (or string) used at the end of a line before a hyphenation break.",
|
||||
"url":"https://www.caniemail.com/features/css-hyphenate-character/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":"hyphens, break",
|
||||
"last_test_date":"2024-06-19",
|
||||
"test_url":"https://www.caniemail.com/tests/css-hyphenate-character.html",
|
||||
"test_results_url":"https://testi.at/proj/vr3e1e5bikda08oxc2",
|
||||
"stats":{"apple-mail":{"macos":{"20":"n","21":"n","22":"n","23":"y"},"ios":{"11":"n","12":"n","13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"n"},"android":{"2024-06":"n"},"mobile-webmail":{"2024-06":"n"}},"orange":{"desktop-webmail":{"2024-06":"u"},"ios":{"2024-06":"u"},"android":{"2024-06":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-06":"n"},"macos":{"2024-06":"n"},"outlook-com":{"2024-06":"n"},"ios":{"2024-06":"n"},"android":{"2024-06":"n"}},"yahoo":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"n"},"android":{"2024-06":"n"}},"aol":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"n"},"android":{"2024-06":"n"}},"samsung-email":{"android":{"2024-06":"y"}},"sfr":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"protonmail":{"desktop-webmail":{"2024-06":"u"},"ios":{"2024-06":"u"},"android":{"2024-06":"u"}},"hey":{"desktop-webmail":{"2024-06":"u"}},"mail-ru":{"desktop-webmail":{"2024-06":"a #1"}},"fastmail":{"desktop-webmail":{"2024-06":"u"}},"laposte":{"desktop-webmail":{"2024-06":"u"}},"gmx":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"u"},"android":{"2024-06":"u"}},"web-de":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"u"},"android":{"2024-06":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-06":"u"},"android":{"2024-06":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Does not support encoded character values"}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-hyphenate-limit-chars",
|
||||
"title":"hyphenate-limit-chars",
|
||||
"description":"Specifies the minimum word length to allow hyphenation of words as well as the minimum number of characters before and after the hyphen.",
|
||||
"url":"https://www.caniemail.com/features/css-hyphenate-limit-chars/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"last_test_date":"2024-08-08",
|
||||
"test_url":"https://www.caniemail.com/tests/css-hyphenate-limit-chars.html",
|
||||
"test_results_url":"https://testi.at/proj/kgljcojhdyrfdv5s2",
|
||||
"stats":{"apple-mail":{"macos":{"2024-08":"n"},"ios":{"2024-08":"n"}},"gmail":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"},"mobile-webmail":{"2024-08":"n"}},"orange":{"desktop-webmail":{"2024-08":"u"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-08":"n"},"macos":{"2024-08":"n"},"outlook-com":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"}},"yahoo":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"}},"aol":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"}},"samsung-email":{"android":{"2024-08":"n"}},"sfr":{"desktop-webmail":{"2024-08":"u"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"thunderbird":{"macos":{"2024-08":"n"}},"protonmail":{"desktop-webmail":{"2024-08":"u"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"hey":{"desktop-webmail":{"2024-08":"u"}},"mail-ru":{"desktop-webmail":{"2024-08":"y"}},"fastmail":{"desktop-webmail":{"2024-08":"u"}},"laposte":{"desktop-webmail":{"2024-08":"u"}},"gmx":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"web-de":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-08":"u"},"android":{"2024-08":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-hyphens",
|
||||
"title":"hyphens",
|
||||
@@ -1075,6 +1155,22 @@
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-inset",
|
||||
"title":"inset",
|
||||
"description":"Shorthand that corresponds to the `top`, `right`, `bottom`, and/or `left` properties",
|
||||
"url":"https://www.caniemail.com/features/css-inset/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"last_test_date":"2024-05-29",
|
||||
"test_url":"https://www.caniemail.com/tests/css-inset.html",
|
||||
"test_results_url":"https://testi.at/proj/rlpdia3k18jytjx8c2",
|
||||
"stats":{"apple-mail":{"macos":{"10.15":"n","11.7":"y"},"ios":{"14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"},"mobile-webmail":{"2020-02":"n"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n"},"windows-mail":{"2024-05":"n"},"macos":{"2024-05":"n"},"outlook-com":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"yahoo":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"aol":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"samsung-email":{"android":{"2024-05":"n"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"thunderbird":{"macos":{"2024-05":"y"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"n"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-intrinsic-size",
|
||||
"title":"fit-content, min-content, max-content",
|
||||
@@ -1310,7 +1406,7 @@
|
||||
"last_test_date":"2019-10-01",
|
||||
"test_url":"https://www.caniemail.com/tests/css-margin.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/UmR6V6XenYY9bQiABuLGZRRrdP3fj2ZraiJjEyi4WKBho/list",
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.4":"y"}},"gmail":{"desktop-webmail":{"2019-10":"a #1"},"ios":{"2019-10":"a #1"},"android":{"2019-10":"a #1"},"mobile-webmail":{"2020-02":"a #1"}},"orange":{"desktop-webmail":{"2019-10":"y","2021-03":"y"},"ios":{"2019-10":"y"},"android":{"2019-10":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #1 #2 #3 #4","2010":"a #1 #2 #3 #4","2013":"a #1 #2 #3 #4","2016":"a #1 #2 #3 #4","2019":"a #1 #2 #3 #4"},"windows-mail":{"2019-10":"a #1 #2 #3"},"macos":{"2011":"y","2016":"y","16.80":"a #1"},"outlook-com":{"2019-10":"a #1","2023-12":"a #1"},"ios":{"2.51.1":"y","4.3.1":"a #1"},"android":{"2019-10":"a #1"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-10":"y"},"ios":{"2019-10":"y"},"android":{"2019-10":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2019-10":"y"},"ios":{"2019-10":"y"},"android":{"2019-10":"y"}},"yahoo":{"desktop-webmail":{"2019-10":"y"},"ios":{"2019-10":"y"},"android":{"2019-10":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"a #1"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.4":"y"}},"gmail":{"desktop-webmail":{"2019-10":"a #1"},"ios":{"2019-10":"a #1"},"android":{"2019-10":"a #1"},"mobile-webmail":{"2020-02":"a #1"}},"orange":{"desktop-webmail":{"2019-10":"y","2021-03":"y"},"ios":{"2019-10":"y"},"android":{"2019-10":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #1 #2 #3 #4","2010":"a #1 #2 #3 #4","2013":"a #1 #2 #3 #4","2016":"a #1 #2 #3 #4","2019":"a #1 #2 #3 #4"},"windows-mail":{"2019-10":"a #1 #2 #3"},"macos":{"2011":"y","2016":"y","16.80":"a #1"},"outlook-com":{"2019-10":"a #1","2023-12":"a #1"},"ios":{"2.51.1":"y","4.3.1":"a #1"},"android":{"2019-10":"a #1"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-10":"y"},"ios":{"2019-10":"y"},"android":{"2019-10":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2019-10":"y","2024-10":"a #1"},"ios":{"2019-10":"y","2024-10":"a #1"},"android":{"2019-10":"y","2024-10":"a #1"}},"yahoo":{"desktop-webmail":{"2019-10":"y","2024-10":"a #1"},"ios":{"2019-10":"y","2024-10":"a #1"},"android":{"2019-10":"y","2024-10":"a #1"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"a #1"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Negative values are not supported.","2":"Partial. Not supported on `<span>` and `<body>` elements.","3":"Buggy. `background-color` is included inside the `margin`.","4":"Partial. `auto` value is not supported."}
|
||||
},
|
||||
@@ -1347,6 +1443,22 @@
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-max-inline-size",
|
||||
"title":"max-inline-size",
|
||||
"description":"Defines the horizontal or vertical maximum size of an element's block, depending on its writing mode",
|
||||
"url":"https://www.caniemail.com/features/css-max-inline-size/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":"max, inline, size",
|
||||
"last_test_date":"2024-05-31",
|
||||
"test_url":"https://www.caniemail.com/tests/css-max-inline-size.html",
|
||||
"test_results_url":"https://testi.at/proj/8r8g0dn81y8jc72z09",
|
||||
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"},"mobile-webmail":{"2024-05":"n"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-05":"n"},"macos":{"2024-05":"n"},"outlook-com":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"yahoo":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"aol":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-max-width",
|
||||
"title":"max-width",
|
||||
@@ -1363,6 +1475,22 @@
|
||||
"notes_by_num":{"1":"Partial. Only works on `<table>` elements.","2":"Partial. Doesn't work on `<table>` elements, as per [CSS 2.1 specification](https://www.w3.org/TR/CSS2/visudet.html#min-max-widths)."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-min-block-size",
|
||||
"title":"min-block-size",
|
||||
"description":"Defines the minimum horizontal or vertical size of an element's block, depending on its writing mode",
|
||||
"url":"https://www.caniemail.com/features/css-min-block-size/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":"min, block, size",
|
||||
"last_test_date":"2024-05-31",
|
||||
"test_url":"https://www.caniemail.com/tests/css-min-block-size.html",
|
||||
"test_results_url":"https://testi.at/proj/73yg05zgtpk3cez6ua5",
|
||||
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"},"mobile-webmail":{"2024-05":"n"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-05":"n"},"macos":{"2024-05":"n"},"outlook-com":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"yahoo":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"aol":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-min-height",
|
||||
"title":"min-height property",
|
||||
@@ -1390,7 +1518,7 @@
|
||||
"last_test_date":"2022-08-30",
|
||||
"test_url":"https://www.caniemail.com/tests/css-min-inline-size.html",
|
||||
"test_results_url":"https://testi.at/proj/6m0cx5puENPh8pLi9rpSPzJSB",
|
||||
"stats":{"apple-mail":{"macos":{"16":"y","17":"y","18":"y","19":"y","20":"y","21":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-08":"n"},"mobile-webmail":{"2022-07":"u"}},"orange":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2022-07":"y","16.80":"n"},"outlook-com":{"2022-07":"n","2024-01":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"samsung-email":{"android":{"2022-07":"y"}},"sfr":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"protonmail":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"hey":{"desktop-webmail":{"2022-07":"u"}},"mail-ru":{"desktop-webmail":{"2022-07":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"u"}}},
|
||||
"stats":{"apple-mail":{"macos":{"16":"y","17":"y","18":"y","19":"y","20":"y","21":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-08":"n"},"mobile-webmail":{"2022-07":"n"}},"orange":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2022-07":"y","16.80":"n"},"outlook-com":{"2022-07":"n","2024-01":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"samsung-email":{"android":{"2022-07":"y"}},"sfr":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"protonmail":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"hey":{"desktop-webmail":{"2022-07":"u"}},"mail-ru":{"desktop-webmail":{"2022-07":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
@@ -1507,6 +1635,22 @@
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-orphans",
|
||||
"title":"orphans",
|
||||
"description":"Sets the minimum number of lines in a block container split on an old page, region or column.",
|
||||
"url":"https://www.caniemail.com/features/css-orphans/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":"columns",
|
||||
"last_test_date":"2024-06-13",
|
||||
"test_url":"https://www.caniemail.com/tests/css-widows.html",
|
||||
"test_results_url":"https://testi.at/proj/6vd8udzx1b5l1vrnsr",
|
||||
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"n #4"},"ios":{"2024-05":"n"},"android":{"2024-05":"n #3"},"mobile-webmail":{"2024-05":"n #1 #2"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-05":"n"},"macos":{"2024-05":"y"},"outlook-com":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"yahoo":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"aol":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y #4"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2024-05":"n #1 #4"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"n #1 #4"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Buggy. `columns` property is stripped which is required for `orphans` to work","2":"Buggy. `orphans` property value is replaced by `auto`","3":"Not supported, but Gmail's default styles on the email message container includes the `orphans` property. These values are inherited by children elements","4":"Webmail rendering depends on browser support"}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-outline-offset",
|
||||
"title":"outline-offset",
|
||||
@@ -2579,6 +2723,22 @@
|
||||
"notes_by_num":{"1":"Partial. Negative values are not supported.","2":"Partial. Hard-coded negative values are not supported, but negative values as a result of the `calc()` function are supported."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-text-justify",
|
||||
"title":"text-justify",
|
||||
"description":"Sets what type of justification should be applied to text when `text-align: justify;` is set on an element.",
|
||||
"url":"https://www.caniemail.com/features/css-text-justify/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"last_test_date":"2024-04-17",
|
||||
"test_url":"https://www.caniemail.com/tests/css-text-justify.html",
|
||||
"test_results_url":"https://testi.at/proj/z7b61px4fel2ivk9sb2",
|
||||
"stats":{"apple-mail":{"macos":{"2024-04":"n #1"},"ios":{"2024-04":"n #1"}},"gmail":{"desktop-webmail":{"2024-04":"a #2"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"},"mobile-webmail":{"2024-04":"a #2"}},"orange":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"outlook":{"windows":{"2013":"a #3","2016":"a #3","2019":"a #3","2021":"a #3"},"windows-mail":{"2024-04":"n #5"},"macos":{"2024-04":"n #1"},"outlook-com":{"2024-04":"a #2"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"yahoo":{"desktop-webmail":{"2024-04":"a #2 #4"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"aol":{"desktop-webmail":{"2024-04":"n #2 #4"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"samsung-email":{"android":{"2024-04":"n #1"}},"sfr":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"thunderbird":{"macos":{"2024-04":"u"}},"protonmail":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"a #2"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-04":"u"}},"gmx":{"desktop-webmail":{"2024-04":"a #2"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"n #1"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Buggy. `text-justify` is stripped","2":"Partial. Depends on browser support","3":"Partial. `text-justify` is stripped except when the value is `inter-character`","4":"Partial. `text-justify` is stripped except when the value is `inter-word` or `distribute`","5":"Buggy. `text-justify` values `none`, `inter-word` and `distribute` are replaced with `inter-ideograph`"}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-text-orientation",
|
||||
"title":"text-orientation",
|
||||
@@ -2990,9 +3150,9 @@
|
||||
"last_test_date":"2020-02-25",
|
||||
"test_url":"https://www.caniemail.com/tests/css-units.html",
|
||||
"test_results_url":"",
|
||||
"stats":{"apple-mail":{"macos":{"13":"y"},"ios":{"13":"y"}},"gmail":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"},"mobile-webmail":{"2020-02":"y"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"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":"y","2021-03":"n","2024-04":"n"},"ios":{"2020-02":"y","2024-04":"n"},"android":{"2020-02":"y","2024-04":"n"}},"sfr":{"desktop-webmail":{"2020-02":"y"},"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":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"}},"web-de":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-08":"y"},"android":{"2022-08":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"13":"y"},"ios":{"13":"y"}},"gmail":{"desktop-webmail":{"2020-02":"y #1"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"},"mobile-webmail":{"2020-02":"y"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-02":"n"},"macos":{"2016":"y","16.80":"y"},"outlook-com":{"2020-02":"y #1","2024-01":"y #1"},"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 #1"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"yahoo":{"desktop-webmail":{"2020-02":"y #1"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-02":"y","2021-03":"n","2024-04":"n"},"ios":{"2020-02":"y","2024-04":"n"},"android":{"2020-02":"y","2024-04":"n"}},"sfr":{"desktop-webmail":{"2020-02":"y"},"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":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y #1"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"}},"web-de":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-08":"y"},"android":{"2022-08":"y"}}},
|
||||
"notes":"",
|
||||
"notes_by_num":{}
|
||||
"notes_by_num":{"1":"The HTML of the email message is embedded directly on the webmail (not in an <iframe>) and may not fill the full viewport's width. In this case, the vw values are relevant to the viewport (browser window) not the email message."}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -3059,6 +3219,22 @@
|
||||
"notes_by_num":{"1":"Buggy. `visibility:collapse` applied to a `<tr>` only hides content and does not \"remove\" it from layout.","2":"Partially supported. `visibility:collapse` is not supported."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-white-space-collapse",
|
||||
"title":"white-space-collapse",
|
||||
"description":"Controls how white space inside an element is collapsed.",
|
||||
"url":"https://www.caniemail.com/features/css-white-space-collapse/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":"break, space, collapse, hide",
|
||||
"last_test_date":"2024-09-04",
|
||||
"test_url":"https://www.caniemail.com/tests/css-white-space-collapse.html",
|
||||
"test_results_url":"https://testi.at/proj/e6y4s3zytp5kty7kcg",
|
||||
"stats":{"apple-mail":{"macos":{"10":"n","11":"n","12":"n","13":"n","14":"y #1"},"ios":{"15":"y #1"}},"gmail":{"desktop-webmail":{"2024-09":"n"},"ios":{"2024-09":"n"},"android":{"2024-09":"n"},"mobile-webmail":{"2024-09":"n"}},"orange":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-09":"n"},"macos":{"2024-09":"n"},"outlook-com":{"2024-09":"n"},"ios":{"2024-09":"n"},"android":{"2024-09":"n"}},"yahoo":{"desktop-webmail":{"2024-09":"n"},"ios":{"2024-09":"n"},"android":{"2024-09":"n"}},"aol":{"desktop-webmail":{"2024-09":"n"},"ios":{"2024-09":"n"},"android":{"2024-09":"n"}},"samsung-email":{"android":{"2024-09":"y #1"}},"sfr":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"protonmail":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"hey":{"desktop-webmail":{"2024-09":"u"}},"mail-ru":{"desktop-webmail":{"2024-09":"y #1"}},"fastmail":{"desktop-webmail":{"2024-09":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. `preserve-spaces` value works only on Firefox."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-white-space",
|
||||
"title":"white-space",
|
||||
@@ -3075,6 +3251,22 @@
|
||||
"notes_by_num":{"1":"Partial. Not supported with non Google accounts.","2":"Partial. `pre` value is not supported."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-widows",
|
||||
"title":"widows",
|
||||
"description":"Sets the minimum number of lines in a block container split on a new page, region or column.",
|
||||
"url":"https://www.caniemail.com/features/css-widows/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":"columns",
|
||||
"last_test_date":"2024-05-03",
|
||||
"test_url":"https://www.caniemail.com/tests/css-widows.html",
|
||||
"test_results_url":"https://testi.at/proj/6vd8udzx1b5l1vrnsr",
|
||||
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"n #4"},"ios":{"2024-05":"n"},"android":{"2024-05":"n #3"},"mobile-webmail":{"2024-05":"n #1 #2"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-05":"n"},"macos":{"2024-05":"y"},"outlook-com":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"yahoo":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"aol":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y #4"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2024-05":"n #1 #4"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"n #1 #4"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Buggy. `columns` property is stripped which is required for `widows` to work","2":"Buggy. `widows` property value is replaced by `auto`","3":"Not supported, but Gmail's default styles on the email message container includes the `widows` property. These values are inherited by children elements","4":"Webmail rendering depends on browser support"}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-width",
|
||||
"title":"width property",
|
||||
@@ -3443,6 +3635,38 @@
|
||||
"notes_by_num":{"1":"Partial. Not supported with non Google accounts.","2":"Partial. The element is present but is not interactive."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"html-cellpadding",
|
||||
"title":"cellpadding attribute",
|
||||
"description":"Represents the padding around the individual cells of the table",
|
||||
"url":"https://www.caniemail.com/features/html-cellpadding/",
|
||||
"category":"html",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"last_test_date":"2024-05-01",
|
||||
"test_url":"https://www.caniemail.com/tests/html-cellspacing.html",
|
||||
"test_results_url":"https://testi.at/proj/rlpatnzxf3eyce9oc3",
|
||||
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"},"mobile-webmail":{"2024-05":"y"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-05":"y"},"macos":{"2024-05":"y"},"outlook-com":{"2024-05":"y","2024-01":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"yahoo":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"aol":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"thunderbird":{"macos":{"2024-05":"y"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2024-05":"u"}},"gmx":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"html-cellspacing",
|
||||
"title":"cellspacing attribute",
|
||||
"description":"Represents the spacing around the individual `<th>` and `<td>` elements",
|
||||
"url":"https://www.caniemail.com/features/html-cellspacing/",
|
||||
"category":"html",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"last_test_date":"2024-05-01",
|
||||
"test_url":"https://www.caniemail.com/tests/html-cellspacing.html",
|
||||
"test_results_url":"https://testi.at/proj/rlpatnzxf3eyce9oc3",
|
||||
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"},"mobile-webmail":{"2024-05":"y"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-05":"y"},"macos":{"2024-05":"y"},"outlook-com":{"2024-05":"y","2024-01":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"yahoo":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"aol":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"thunderbird":{"macos":{"2024-05":"y"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2024-05":"u"}},"gmx":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"html-code",
|
||||
"title":"<code> element",
|
||||
@@ -3459,6 +3683,22 @@
|
||||
"notes_by_num":{"1":"Not supported. The tags are removed but the content is kept."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"html-comments",
|
||||
"title":"HTML comments",
|
||||
"description":"Adds explanatory notes to the code or to prevent the browser from interpreting specific parts of the style sheet",
|
||||
"url":"https://www.caniemail.com/features/html-comments/",
|
||||
"category":"html",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"last_test_date":"2024-05-1",
|
||||
"test_url":"https://www.caniemail.com/tests/css-comments.html",
|
||||
"test_results_url":"https://testi.at/proj/n4ayign05k6cozot6",
|
||||
"stats":{"apple-mail":{"macos":{"2024-04":"y"},"ios":{"2024-04":"y"}},"gmail":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"},"mobile-webmail":{"2024-04":"y"}},"orange":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-04":"y"},"macos":{"2024-04":"y"},"outlook-com":{"2024-04":"y","2024-01":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"yahoo":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"aol":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"samsung-email":{"android":{"2024-04":"y"}},"sfr":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"thunderbird":{"macos":{"2024-04":"y"}},"protonmail":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"y"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-04":"u"}},"gmx":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"html-del",
|
||||
"title":"<del> element",
|
||||
@@ -3931,12 +4171,12 @@
|
||||
"category":"html",
|
||||
"tags":["accessibility","performance"],
|
||||
"keywords":"picture, responsive image",
|
||||
"last_test_date":"2019-05-29",
|
||||
"last_test_date":"2024-04-15",
|
||||
"test_url":"https://www.caniemail.com/tests/html-picture.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/AQoLHTLaC6F6JcMrkx38M7oyiJlAlXeRnJgkK06bSJiBR/list",
|
||||
"stats":{"apple-mail":{"macos":{"10.3":"y"},"ios":{"10.3":"y","12.2":"y"}},"gmail":{"desktop-webmail":{"2019-05":"n #1"},"ios":{"2019-05":"n #1"},"android":{"2019-05":"n #1"},"mobile-webmail":{"2020-02":"n #1"}},"orange":{"desktop-webmail":{"2019-05":"y","2021-03":"n","2024-04":"n"},"ios":{"2019-05":"y","2024-04":"n"},"android":{"2019-05":"y","2024-04":"n"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-01":"n"},"macos":{"2011":"y","2016":"y","16.62":"n","16.80":"n"},"outlook-com":{"2019-05":"n","2024-01":"n"},"ios":{"2019-05":"n"},"android":{"2019-05":"n"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-05":"y"},"ios":{"2019-05":"y"},"android":{"2019-05":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2020-01":"n"},"ios":{"2020-01":"n"},"android":{"2020-01":"n"}},"yahoo":{"desktop-webmail":{"2019-05":"n"},"ios":{"2019-05":"n"},"android":{"2019-05":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"n"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-11":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"web-de":{"desktop-webmail":{"2022-11":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"n"},"android":{"2022-11":"n"}}},
|
||||
"test_results_url":"https://testi.at/proj/vr32cxxk1exntxrjfdp",
|
||||
"stats":{"apple-mail":{"macos":{"10.3":"y","10.15":"a #2","11.7":"a #2","12.7":"a #2","13.6":"a #2","14.4":"a #2"},"ios":{"10.3":"y","12.2":"y"}},"gmail":{"desktop-webmail":{"2019-05":"n #1"},"ios":{"2019-05":"n #1"},"android":{"2019-05":"n #1"},"mobile-webmail":{"2020-02":"n #1"}},"orange":{"desktop-webmail":{"2019-05":"y","2021-03":"n","2024-04":"n"},"ios":{"2019-05":"y","2024-04":"n"},"android":{"2019-05":"y","2024-04":"n"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-01":"n"},"macos":{"2011":"y","2016":"y","16.62":"n","16.80":"n"},"outlook-com":{"2019-05":"n","2024-01":"n"},"ios":{"2019-05":"n"},"android":{"2019-05":"n"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-05":"y"},"ios":{"2019-05":"y"},"android":{"2019-05":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2020-01":"n"},"ios":{"2020-01":"n"},"android":{"2020-01":"n"}},"yahoo":{"desktop-webmail":{"2019-05":"n"},"ios":{"2019-05":"n"},"android":{"2019-05":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"n"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-11":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"web-de":{"desktop-webmail":{"2022-11":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"n"},"android":{"2022-11":"n"}}},
|
||||
"notes":"",
|
||||
"notes_by_num":{"1":"`<picture>` and `<source>` tags are replaced by `<u></u>` tags."}
|
||||
"notes_by_num":{"1":"`<picture>` and `<source>` tags are replaced by `<u></u>` tags.","2":"`<picture>` tag is stripped in some cases (like having too few content or no background-color)."}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -4366,7 +4606,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."}
|
||||
},
|
||||
@@ -4510,7 +4750,7 @@
|
||||
"last_test_date":"2023-01-15",
|
||||
"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":"n","14":"y"},"ios":{"13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2020-02":"n","2023-01":"n"},"ios":{"2020-02":"a #1","2023-01":"a #1"},"android":{"2020-02":"a #1","2023-01":"a #1"},"mobile-webmail":{"2020-02":"n","2023-01":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-02":"y"},"macos":{"2016":"n","13.1":"y","16.80":"y"},"outlook-com":{"2020-02":"y","2024-01":"y"},"ios":{"2020-02":"n","2023-01":"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":"y","2021-03":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"sfr":{"desktop-webmail":{"2020-02":"y"},"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":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"n","2023-01":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"13":"n","14":"y"},"ios":{"13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2020-02":"n","2023-01":"n","2024-07":"n"},"ios":{"2020-02":"a #1","2023-01":"a #1"},"android":{"2020-02":"a #1","2023-01":"a #1"},"mobile-webmail":{"2020-02":"n","2023-01":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"y"},"windows-mail":{"2020-02":"y"},"macos":{"2016":"n","13.1":"y","16.80":"y"},"outlook-com":{"2020-02":"y","2024-01":"y"},"ios":{"2020-02":"n","2023-01":"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":"y","2021-03":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"sfr":{"desktop-webmail":{"2020-02":"y"},"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":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"n","2023-01":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"y"},"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":"Partially supported. Only works with non Google accounts."}
|
||||
},
|
||||
|
||||
71
internal/linkcheck/linkcheck_test.go
Normal file
71
internal/linkcheck/linkcheck_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package linkcheck
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
)
|
||||
|
||||
var (
|
||||
testHTML = `
|
||||
<html>
|
||||
<head>
|
||||
<link rel=stylesheet href="http://remote-host/style.css"></link>
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=ignored"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<p><a href="http://example.com">HTTP link</a></p>
|
||||
<p><a href="https://example.com">HTTPS link</a></p>
|
||||
<p><a href="HTTPS://EXAMPLE.COM">HTTPS link</a></p>
|
||||
<p><a href="http://localhost">Localhost link</a> (ignored)</p>
|
||||
<p><a href="https://localhost">Localhost link</a> (ignored)</p>
|
||||
<p><a href='https://127.0.0.1'>Single quotes link</a> (ignored)</p>
|
||||
<p><img src=https://example.com/image.jpg></p>
|
||||
<p href="http://invalid-link.com">This should be ignored</p>
|
||||
<p><a href="http://link with spaces">Link with spaces</a></p>
|
||||
<p><a href="http://example.com/?blaah=yes&test=true">URL-encoded characters</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
expectedHTMLLinks = []string{
|
||||
"http://example.com", "https://example.com", "HTTPS://EXAMPLE.COM", "http://localhost", "https://localhost", "https://127.0.0.1", "http://link with spaces", "http://example.com/?blaah=yes&test=true",
|
||||
"http://remote-host/style.css", // css
|
||||
"https://example.com/image.jpg", // images
|
||||
}
|
||||
|
||||
testTextLinks = `This is a line with http://example.com https://example.com
|
||||
HTTPS://EXAMPLE.COM
|
||||
[http://localhost]
|
||||
www.google.com < ignored
|
||||
|||http://example.com/?some=query-string|||
|
||||
`
|
||||
|
||||
expectedTextLinks = []string{
|
||||
"http://example.com", "https://example.com", "HTTPS://EXAMPLE.COM", "http://localhost", "http://example.com/?some=query-string",
|
||||
}
|
||||
)
|
||||
|
||||
func TestLinkDetection(t *testing.T) {
|
||||
|
||||
t.Log("Testing HTML link detection")
|
||||
|
||||
m := storage.Message{}
|
||||
|
||||
m.Text = testTextLinks
|
||||
m.HTML = testHTML
|
||||
|
||||
textLinks := extractTextLinks(&m)
|
||||
|
||||
if !reflect.DeepEqual(textLinks, expectedTextLinks) {
|
||||
t.Fatalf("Failed to detect text links correctly")
|
||||
}
|
||||
|
||||
htmlLinks := extractHTMLLinks(&m)
|
||||
|
||||
if !reflect.DeepEqual(htmlLinks, expectedHTMLLinks) {
|
||||
t.Fatalf("Failed to detect HTML links correctly")
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
var linkRe = regexp.MustCompile(`(?m)\b(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:'!\/~+#-]*[\w@?^=%&\/~+#-])`)
|
||||
var linkRe = regexp.MustCompile(`(?im)\b(http|https):\/\/([\-\w@:%_\+'!.~#?,&\/\/=;]+)`)
|
||||
|
||||
// RunTests will run all tests on an HTML string
|
||||
func RunTests(msg *storage.Message, followRedirects bool) (Response, error) {
|
||||
|
||||
453
internal/pop3client/client.go
Normal file
453
internal/pop3client/client.go
Normal file
@@ -0,0 +1,453 @@
|
||||
// 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 name
|
||||
Host string `json:"host"`
|
||||
// Port number
|
||||
Port int `json:"port"`
|
||||
// DialTimeout default is 3 seconds.
|
||||
DialTimeout time.Duration `json:"dial_timeout"`
|
||||
// Dialer
|
||||
Dialer Dialer `json:"-"`
|
||||
// TLSEnabled sets whether SLS is enabled
|
||||
TLSEnabled bool `json:"tls_enabled"`
|
||||
// TLSSkipVerify skips TLS verification (ie: self-signed)
|
||||
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 in bytes
|
||||
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))
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func Ping() error {
|
||||
}
|
||||
|
||||
var client *spamc.Client
|
||||
if strings.HasPrefix("unix:", service) {
|
||||
if strings.HasPrefix(service, "unix:") {
|
||||
client = spamc.NewUnix(strings.TrimLeft(service, "unix:"))
|
||||
} else {
|
||||
client = spamc.NewTCP(service, timeout)
|
||||
@@ -112,7 +112,7 @@ func Check(msg []byte) (Result, error) {
|
||||
}
|
||||
} else {
|
||||
var client *spamc.Client
|
||||
if strings.HasPrefix("unix:", service) {
|
||||
if strings.HasPrefix(service, "unix:") {
|
||||
client = spamc.NewUnix(strings.TrimLeft(service, "unix:"))
|
||||
} else {
|
||||
client = spamc.NewTCP(service, timeout)
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
// ProtoVersion is the protocol version
|
||||
@@ -81,6 +83,7 @@ func (c *Client) dial() (connection, error) {
|
||||
}
|
||||
return net.DialUnix("unix", nil, unixAddr)
|
||||
}
|
||||
|
||||
panic("Client.net must be either \"tcp\" or \"unix\"")
|
||||
}
|
||||
|
||||
@@ -107,26 +110,25 @@ func (c *Client) report(email []byte) ([]string, error) {
|
||||
}
|
||||
|
||||
bw := bufio.NewWriter(conn)
|
||||
_, err = bw.WriteString("REPORT SPAMC/" + ProtoVersion + "\r\n")
|
||||
if err != nil {
|
||||
if _, err := bw.WriteString("REPORT SPAMC/" + ProtoVersion + "\r\n"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = bw.WriteString("Content-length: " + strconv.Itoa(len(email)) + "\r\n\r\n")
|
||||
if err != nil {
|
||||
|
||||
if _, err := bw.WriteString("Content-length: " + strconv.Itoa(len(email)) + "\r\n\r\n"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = bw.Write(email)
|
||||
if err != nil {
|
||||
|
||||
if _, err := bw.Write(email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = bw.Flush()
|
||||
if err != nil {
|
||||
|
||||
if err := bw.Flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Client is supposed to close its writing side of the connection
|
||||
// after sending its request.
|
||||
err = conn.CloseWrite()
|
||||
if err != nil {
|
||||
if err := conn.CloseWrite(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -134,6 +136,7 @@ func (c *Client) report(email []byte) ([]string, error) {
|
||||
lines []string
|
||||
br = bufio.NewReader(conn)
|
||||
)
|
||||
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err == io.EOF {
|
||||
@@ -171,11 +174,12 @@ func (c *Client) parseOutput(output []string) Result {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// summary
|
||||
if spamMainRe.MatchString(row) {
|
||||
res := spamMainRe.FindStringSubmatch(row)
|
||||
if len(res) == 4 {
|
||||
if strings.ToLower(res[1]) == "true" || strings.ToLower(res[1]) == "yes" {
|
||||
if tools.InArray(res[1], []string{"true", "yes"}) {
|
||||
result.Spam = true
|
||||
} else {
|
||||
result.Spam = false
|
||||
@@ -197,8 +201,8 @@ func (c *Client) parseOutput(output []string) Result {
|
||||
reachedRules = true
|
||||
continue
|
||||
}
|
||||
|
||||
// details
|
||||
// row = strings.Trim(row, " \t\r\n")
|
||||
if reachedRules && spamDetailsRe.MatchString(row) {
|
||||
res := spamDetailsRe.FindStringSubmatch(row)
|
||||
if len(res) == 5 {
|
||||
@@ -207,6 +211,7 @@ func (c *Client) parseOutput(output []string) Result {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -222,12 +227,11 @@ func (c *Client) Ping() error {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.WriteString(conn, fmt.Sprintf("PING SPAMC/%s\r\n\r\n", ProtoVersion))
|
||||
if err != nil {
|
||||
if _, err := io.WriteString(conn, fmt.Sprintf("PING SPAMC/%s\r\n\r\n", ProtoVersion)); err != nil {
|
||||
return err
|
||||
}
|
||||
err = conn.CloseWrite()
|
||||
if err != nil {
|
||||
|
||||
if err := conn.CloseWrite(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -241,5 +245,6 @@ func (c *Client) Ping() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
@@ -48,34 +49,74 @@ func dbCron() {
|
||||
// PruneMessages will auto-delete the oldest messages if messages > config.MaxMessages.
|
||||
// Set config.MaxMessages to 0 to disable.
|
||||
func pruneMessages() {
|
||||
if config.MaxMessages < 1 {
|
||||
if config.MaxMessages < 1 && config.MaxAgeInHours == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
q := sqlf.Select("ID, Size").
|
||||
From(tenant("mailbox")).
|
||||
OrderBy("Created DESC").
|
||||
Limit(5000).
|
||||
Offset(config.MaxMessages)
|
||||
|
||||
ids := []string{}
|
||||
var prunedSize int64
|
||||
var size float64
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var id string
|
||||
|
||||
if err := row.Scan(&id, &size); err != nil {
|
||||
// prune using `--max` if set
|
||||
if config.MaxMessages > 0 {
|
||||
total := CountTotal()
|
||||
if total > float64(config.MaxAgeInHours) {
|
||||
offset := config.MaxMessages
|
||||
if config.DemoMode {
|
||||
offset = 500
|
||||
}
|
||||
q := sqlf.Select("ID, Size").
|
||||
From(tenant("mailbox")).
|
||||
OrderBy("Created DESC").
|
||||
Limit(5000).
|
||||
Offset(offset)
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var id string
|
||||
|
||||
if err := row.Scan(&id, &size); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
prunedSize = prunedSize + int64(size)
|
||||
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// prune using `--max-age` if set
|
||||
if config.MaxAgeInHours > 0 {
|
||||
// now() minus the number of hours
|
||||
ts := time.Now().Add(time.Duration(-config.MaxAgeInHours) * time.Hour).UnixMilli()
|
||||
|
||||
q := sqlf.Select("ID, Size").
|
||||
From(tenant("mailbox")).
|
||||
Where("Created < ?", ts).
|
||||
Limit(5000)
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var id string
|
||||
|
||||
if err := row.Scan(&id, &size); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if !tools.InArray(id, ids) {
|
||||
ids = append(ids, id)
|
||||
prunedSize = prunedSize + int64(size)
|
||||
}
|
||||
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
prunedSize = prunedSize + int64(size)
|
||||
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
@@ -132,6 +173,10 @@ func pruneMessages() {
|
||||
|
||||
logMessagesDeleted(len(ids))
|
||||
|
||||
if config.DemoMode {
|
||||
vacuumDb()
|
||||
}
|
||||
|
||||
websockets.Broadcast("prune", nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -39,8 +39,12 @@ var (
|
||||
|
||||
// InitDB will initialise the database
|
||||
func InitDB() error {
|
||||
var (
|
||||
dsn string
|
||||
err error
|
||||
)
|
||||
|
||||
p := config.Database
|
||||
var dsn string
|
||||
|
||||
if p == "" {
|
||||
// when no path is provided then we create a temporary file
|
||||
@@ -74,8 +78,6 @@ func InitDB() error {
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
db, err = sql.Open(sqlDriver, dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -27,8 +27,10 @@ import (
|
||||
// Store will save an email to the database tables.
|
||||
// Returns the database ID of the saved message.
|
||||
func Store(body *[]byte) (string, error) {
|
||||
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
|
||||
|
||||
// Parse message body with enmime
|
||||
env, err := enmime.ReadEnvelope(bytes.NewReader(*body))
|
||||
env, err := parser.ReadEnvelope(bytes.NewReader(*body))
|
||||
if err != nil {
|
||||
logger.Log().Warnf("[message] %s", err.Error())
|
||||
return "", nil
|
||||
@@ -50,7 +52,7 @@ func Store(body *[]byte) (string, error) {
|
||||
ReplyTo: addressToSlice(env, "Reply-To"),
|
||||
}
|
||||
|
||||
messageID := strings.Trim(env.Root.Header.Get("Message-ID"), "<>")
|
||||
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
|
||||
created := time.Now()
|
||||
|
||||
// use message date instead of created date
|
||||
@@ -112,23 +114,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.GetHeader("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 +152,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)
|
||||
@@ -154,12 +162,14 @@ func Store(body *[]byte) (string, error) {
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
logger.Log().Debugf("[db] saved message %s (%d bytes)", id, int64(size))
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// List returns a subset of messages from the mailbox,
|
||||
// sorted latest to oldest
|
||||
func List(start, limit int) ([]MessageSummary, error) {
|
||||
func List(start int, beforeTS int64, limit int) ([]MessageSummary, error) {
|
||||
results := []MessageSummary{}
|
||||
tsStart := time.Now()
|
||||
|
||||
@@ -169,6 +179,10 @@ func List(start, limit int) ([]MessageSummary, error) {
|
||||
Limit(limit).
|
||||
Offset(start)
|
||||
|
||||
if beforeTS > 0 {
|
||||
q = q.Where("Created < ?", beforeTS)
|
||||
}
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
var id string
|
||||
@@ -233,7 +247,9 @@ func GetMessage(id string) (*Message, error) {
|
||||
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
|
||||
|
||||
env, err := parser.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -384,7 +400,9 @@ func GetAttachmentPart(id, partID string) (*enmime.Part, error) {
|
||||
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
|
||||
|
||||
env, err := parser.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -412,6 +430,21 @@ func GetAttachmentPart(id, partID string) (*enmime.Part, error) {
|
||||
return nil, errors.New("attachment not found")
|
||||
}
|
||||
|
||||
// AttachmentSummary returns a summary of the attachment without any binary data
|
||||
func AttachmentSummary(a *enmime.Part) Attachment {
|
||||
o := Attachment{}
|
||||
o.PartID = a.PartID
|
||||
o.FileName = a.FileName
|
||||
if o.FileName == "" {
|
||||
o.FileName = a.ContentID
|
||||
}
|
||||
o.ContentType = a.ContentType
|
||||
o.ContentID = a.ContentID
|
||||
o.Size = float64(len(a.Content))
|
||||
|
||||
return o
|
||||
}
|
||||
|
||||
// LatestID returns the latest message ID
|
||||
//
|
||||
// If a query argument is set in the request the function will return the
|
||||
@@ -422,12 +455,12 @@ func LatestID(r *http.Request) (string, error) {
|
||||
|
||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
if search != "" {
|
||||
messages, _, err = Search(search, r.URL.Query().Get("tz"), 0, 1)
|
||||
messages, _, err = Search(search, r.URL.Query().Get("tz"), 0, 0, 1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
messages, err = List(0, 1)
|
||||
messages, err = List(0, 0, 1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -456,6 +489,13 @@ func MarkRead(id string) error {
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
d := struct {
|
||||
ID string
|
||||
Read bool
|
||||
}{ID: id, Read: true}
|
||||
|
||||
websockets.Broadcast("update", d)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -528,6 +568,13 @@ func MarkUnread(id string) error {
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
d := struct {
|
||||
ID string
|
||||
Read bool
|
||||
}{ID: id, Read: false}
|
||||
|
||||
websockets.Broadcast("update", d)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -593,7 +640,9 @@ func DeleteMessages(ids []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
addDeletedSize(int64(totalSize))
|
||||
@@ -613,6 +662,15 @@ func DeleteMessages(ids []string) error {
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
// broadcast individual message deletions
|
||||
for _, id := range toDelete {
|
||||
d := struct {
|
||||
ID string
|
||||
}{ID: id}
|
||||
|
||||
websockets.Broadcast("delete", d)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -663,8 +721,9 @@ func DeleteAllMessages() error {
|
||||
|
||||
logMessagesDeleted(total)
|
||||
|
||||
websockets.Broadcast("prune", nil)
|
||||
BroadcastMailboxStats()
|
||||
|
||||
websockets.Broadcast("truncate", nil)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ package storage
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
)
|
||||
|
||||
func TestTextEmailInserts(t *testing.T) {
|
||||
setup()
|
||||
setup("")
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing text email storage")
|
||||
@@ -38,113 +40,140 @@ func TestTextEmailInserts(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMimeEmailInserts(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
|
||||
tenantID = config.DBTenantID(tenantID)
|
||||
|
||||
t.Log("Testing mime email storage")
|
||||
setup(tenantID)
|
||||
|
||||
start := time.Now()
|
||||
if tenantID == "" {
|
||||
t.Log("Testing mime email storage")
|
||||
} else {
|
||||
t.Logf("Testing mime email storage (tenant %s)", tenantID)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), float64(testRuns), "Incorrect number of mime emails stored")
|
||||
|
||||
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
|
||||
|
||||
delStart := time.Now()
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), float64(0), "incorrect number of mime emails deleted")
|
||||
|
||||
t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart))
|
||||
|
||||
Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrieveMimeEmail(t *testing.T) {
|
||||
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
|
||||
tenantID = config.DBTenantID(tenantID)
|
||||
|
||||
setup(tenantID)
|
||||
|
||||
if tenantID == "" {
|
||||
t.Log("Testing mime email retrieval")
|
||||
} else {
|
||||
t.Logf("Testing mime email retrieval (tenant %s)", tenantID)
|
||||
}
|
||||
|
||||
id, err := Store(&testMimeEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
msg, err := GetMessage(id)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
|
||||
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
|
||||
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
|
||||
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
|
||||
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
|
||||
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
|
||||
assertEqual(t, len(msg.Attachments), 1, "incorrect number of attachments")
|
||||
assertEqual(t, msg.Attachments[0].FileName, "Sample PDF.pdf", "attachment filename does not match")
|
||||
assertEqual(t, len(msg.Inline), 1, "incorrect number of inline attachments")
|
||||
assertEqual(t, msg.Inline[0].FileName, "inline-image.jpg", "inline attachment filename does not match")
|
||||
|
||||
attachmentData, err := GetAttachmentPart(id, msg.Attachments[0].PartID)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, float64(len(attachmentData.Content)), msg.Attachments[0].Size, "attachment size does not match")
|
||||
|
||||
inlineData, err := GetAttachmentPart(id, msg.Inline[0].PartID)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, float64(len(inlineData.Content)), msg.Inline[0].Size, "inline attachment size does not match")
|
||||
|
||||
Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageSummary(t *testing.T) {
|
||||
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
|
||||
tenantID = config.DBTenantID(tenantID)
|
||||
|
||||
setup(tenantID)
|
||||
|
||||
if tenantID == "" {
|
||||
t.Log("Testing message summary")
|
||||
} else {
|
||||
t.Logf("Testing message summary (tenant %s)", tenantID)
|
||||
}
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
summaries, err := List(0, 0, 1)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, len(summaries), 1, "Expected 1 result")
|
||||
|
||||
msg := summaries[0]
|
||||
|
||||
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
|
||||
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
|
||||
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
|
||||
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
|
||||
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
|
||||
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
|
||||
assertEqual(t, msg.Snippet, "Message with inline image and attachment:", "\"Snippet\" does does not match")
|
||||
assertEqual(t, msg.Attachments, 1, "Expected 1 attachment")
|
||||
assertEqual(t, msg.MessageID, "33af2ac1-c33d-9738-35e3-a6daf90bbd89@gmail.com", "\"MessageID\" does not match")
|
||||
|
||||
Close()
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), float64(testRuns), "Incorrect number of mime emails stored")
|
||||
|
||||
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
|
||||
|
||||
delStart := time.Now()
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), float64(0), "incorrect number of mime emails deleted")
|
||||
|
||||
t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart))
|
||||
}
|
||||
|
||||
func TestRetrieveMimeEmail(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing mime email retrieval")
|
||||
|
||||
id, err := Store(&testMimeEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
msg, err := GetMessage(id)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
|
||||
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
|
||||
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
|
||||
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
|
||||
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
|
||||
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
|
||||
assertEqual(t, len(msg.Attachments), 1, "incorrect number of attachments")
|
||||
assertEqual(t, msg.Attachments[0].FileName, "Sample PDF.pdf", "attachment filename does not match")
|
||||
assertEqual(t, len(msg.Inline), 1, "incorrect number of inline attachments")
|
||||
assertEqual(t, msg.Inline[0].FileName, "inline-image.jpg", "inline attachment filename does not match")
|
||||
|
||||
attachmentData, err := GetAttachmentPart(id, msg.Attachments[0].PartID)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, float64(len(attachmentData.Content)), msg.Attachments[0].Size, "attachment size does not match")
|
||||
|
||||
inlineData, err := GetAttachmentPart(id, msg.Inline[0].PartID)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, float64(len(inlineData.Content)), msg.Inline[0].Size, "inline attachment size does not match")
|
||||
}
|
||||
|
||||
func TestMessageSummary(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing message summary")
|
||||
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
summaries, err := List(0, 1)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, len(summaries), 1, "Expected 1 result")
|
||||
|
||||
msg := summaries[0]
|
||||
|
||||
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
|
||||
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
|
||||
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
|
||||
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
|
||||
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
|
||||
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
|
||||
assertEqual(t, msg.Snippet, "Message with inline image and attachment:", "\"Snippet\" does does not match")
|
||||
assertEqual(t, msg.Attachments, 1, "Expected 1 attachment")
|
||||
assertEqual(t, msg.MessageID, "33af2ac1-c33d-9738-35e3-a6daf90bbd89@gmail.com", "\"MessageID\" does not match")
|
||||
}
|
||||
|
||||
func BenchmarkImportText(b *testing.B) {
|
||||
setup()
|
||||
setup("")
|
||||
defer Close()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
@@ -156,7 +185,7 @@ func BenchmarkImportText(b *testing.B) {
|
||||
}
|
||||
|
||||
func BenchmarkImportMime(b *testing.B) {
|
||||
setup()
|
||||
setup("")
|
||||
defer Close()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
|
||||
@@ -43,12 +43,18 @@ func ReindexAll() {
|
||||
logger.Log().Infof("reindexing %d messages", total)
|
||||
|
||||
type updateStruct struct {
|
||||
ID string
|
||||
// ID in database
|
||||
ID string
|
||||
// SearchText for searching
|
||||
SearchText string
|
||||
Snippet string
|
||||
Metadata string
|
||||
// Snippet for UI
|
||||
Snippet string
|
||||
// Metadata info
|
||||
Metadata string
|
||||
}
|
||||
|
||||
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
|
||||
|
||||
for _, ids := range chunks {
|
||||
updates := []updateStruct{}
|
||||
|
||||
@@ -61,7 +67,7 @@ func ReindexAll() {
|
||||
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
env, err := parser.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[message] %s", err.Error())
|
||||
continue
|
||||
@@ -135,5 +141,6 @@ func chunkBy[T any](items []T, chunkSize int) (chunks [][]T) {
|
||||
for chunkSize < len(items) {
|
||||
items, chunks = items[chunkSize:], append(chunks, items[0:chunkSize:chunkSize])
|
||||
}
|
||||
|
||||
return append(chunks, items)
|
||||
}
|
||||
|
||||
@@ -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")).
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
|
||||
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
|
||||
// Negative searches also also included by prefixing the search term with a `-` or `!`
|
||||
func Search(search, timezone string, start, limit int) ([]MessageSummary, int, error) {
|
||||
func Search(search, timezone string, start int, beforeTS int64, limit int) ([]MessageSummary, int, error) {
|
||||
results := []MessageSummary{}
|
||||
allResults := []MessageSummary{}
|
||||
tsStart := time.Now()
|
||||
@@ -28,6 +28,11 @@ func Search(search, timezone string, start, limit int) ([]MessageSummary, int, e
|
||||
}
|
||||
|
||||
q := searchQueryBuilder(search, timezone)
|
||||
|
||||
if beforeTS > 0 {
|
||||
q = q.Where(`Created < ?`, beforeTS)
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
@@ -193,6 +198,7 @@ func DeleteSearch(search, timezone string) error {
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
addDeletedSize(int64(deleteSize))
|
||||
|
||||
logMessagesDeleted(total)
|
||||
|
||||
@@ -6,133 +6,154 @@ import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
|
||||
tenantID = config.DBTenantID(tenantID)
|
||||
|
||||
t.Log("Testing search")
|
||||
for i := 0; i < testRuns; i++ {
|
||||
msg := enmime.Builder().
|
||||
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
|
||||
CC(fmt.Sprintf("CC %d", i), fmt.Sprintf("cc-%d@example.com", i)).
|
||||
CC(fmt.Sprintf("CC2 %d", i), fmt.Sprintf("cc2-%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)).
|
||||
To(fmt.Sprintf("To2 %d", i), fmt.Sprintf("to2-%d@example.com", i)).
|
||||
ReplyTo(fmt.Sprintf("Reply To %d", i), fmt.Sprintf("reply-to-%d@example.com", i))
|
||||
setup(tenantID)
|
||||
|
||||
env, err := msg.Build()
|
||||
if tenantID == "" {
|
||||
t.Log("Testing search")
|
||||
} else {
|
||||
t.Logf("Testing search (tenant %s)", tenantID)
|
||||
}
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
msg := enmime.Builder().
|
||||
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
|
||||
CC(fmt.Sprintf("CC %d", i), fmt.Sprintf("cc-%d@example.com", i)).
|
||||
CC(fmt.Sprintf("CC2 %d", i), fmt.Sprintf("cc2-%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)).
|
||||
To(fmt.Sprintf("To2 %d", i), fmt.Sprintf("to2-%d@example.com", i)).
|
||||
ReplyTo(fmt.Sprintf("Reply To %d", i), fmt.Sprintf("reply-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()
|
||||
|
||||
if _, err := Store(&bufBytes); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
for i := 1; i < 51; i++ {
|
||||
// search a random something that will return a single result
|
||||
uniqueSearches := []string{
|
||||
fmt.Sprintf("from-%d@example.com", i),
|
||||
fmt.Sprintf("from:from-%d@example.com", i),
|
||||
fmt.Sprintf("to-%d@example.com", i),
|
||||
fmt.Sprintf("to:to-%d@example.com", i),
|
||||
fmt.Sprintf("to2-%d@example.com", i),
|
||||
fmt.Sprintf("to:to2-%d@example.com", i),
|
||||
fmt.Sprintf("cc-%d@example.com", i),
|
||||
fmt.Sprintf("cc:cc-%d@example.com", i),
|
||||
fmt.Sprintf("cc2-%d@example.com", i),
|
||||
fmt.Sprintf("cc:cc2-%d@example.com", i),
|
||||
fmt.Sprintf("reply-to-%d@example.com", i),
|
||||
fmt.Sprintf("reply-to:\"reply-to-%d@example.com\"", i),
|
||||
fmt.Sprintf("\"Subject line %d end\"", i),
|
||||
fmt.Sprintf("subject:\"Subject line %d end\"", i),
|
||||
fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i),
|
||||
}
|
||||
searchIdx := rand.Intn(len(uniqueSearches))
|
||||
|
||||
search := uniqueSearches[searchIdx]
|
||||
|
||||
summaries, _, err := Search(search, "", 0, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, len(summaries), 1, "search result expected")
|
||||
|
||||
assertEqual(t, summaries[0].From.Name, fmt.Sprintf("From %d", i), "\"From\" name does not match")
|
||||
assertEqual(t, summaries[0].From.Address, fmt.Sprintf("from-%d@example.com", i), "\"From\" address does not match")
|
||||
assertEqual(t, summaries[0].To[0].Name, fmt.Sprintf("To %d", i), "\"To\" name does not match")
|
||||
assertEqual(t, summaries[0].To[0].Address, fmt.Sprintf("to-%d@example.com", i), "\"To\" address does not match")
|
||||
assertEqual(t, summaries[0].Subject, fmt.Sprintf("Subject line %d end", i), "\"Subject\" does not match")
|
||||
}
|
||||
|
||||
// search something that will return 200 results
|
||||
summaries, _, err := Search("This is the email body", "", 0, 0, testRuns)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, len(summaries), testRuns, "search results expected")
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
if err := env.Encode(buf); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
bufBytes := buf.Bytes()
|
||||
|
||||
if _, err := Store(&bufBytes); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
Close()
|
||||
}
|
||||
|
||||
for i := 1; i < 51; i++ {
|
||||
// search a random something that will return a single result
|
||||
uniqueSearches := []string{
|
||||
fmt.Sprintf("from-%d@example.com", i),
|
||||
fmt.Sprintf("from:from-%d@example.com", i),
|
||||
fmt.Sprintf("to-%d@example.com", i),
|
||||
fmt.Sprintf("to:to-%d@example.com", i),
|
||||
fmt.Sprintf("to2-%d@example.com", i),
|
||||
fmt.Sprintf("to:to2-%d@example.com", i),
|
||||
fmt.Sprintf("cc-%d@example.com", i),
|
||||
fmt.Sprintf("cc:cc-%d@example.com", i),
|
||||
fmt.Sprintf("cc2-%d@example.com", i),
|
||||
fmt.Sprintf("cc:cc2-%d@example.com", i),
|
||||
fmt.Sprintf("reply-to-%d@example.com", i),
|
||||
fmt.Sprintf("reply-to:\"reply-to-%d@example.com\"", i),
|
||||
fmt.Sprintf("\"Subject line %d end\"", i),
|
||||
fmt.Sprintf("subject:\"Subject line %d end\"", i),
|
||||
fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i),
|
||||
}
|
||||
searchIdx := rand.Intn(len(uniqueSearches))
|
||||
|
||||
search := uniqueSearches[searchIdx]
|
||||
|
||||
summaries, _, err := Search(search, "", 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, len(summaries), 1, "search result expected")
|
||||
|
||||
assertEqual(t, summaries[0].From.Name, fmt.Sprintf("From %d", i), "\"From\" name does not match")
|
||||
assertEqual(t, summaries[0].From.Address, fmt.Sprintf("from-%d@example.com", i), "\"From\" address does not match")
|
||||
assertEqual(t, summaries[0].To[0].Name, fmt.Sprintf("To %d", i), "\"To\" name does not match")
|
||||
assertEqual(t, summaries[0].To[0].Address, fmt.Sprintf("to-%d@example.com", i), "\"To\" address does not match")
|
||||
assertEqual(t, summaries[0].Subject, fmt.Sprintf("Subject line %d end", i), "\"Subject\" does not match")
|
||||
}
|
||||
|
||||
// search something that will return 200 results
|
||||
summaries, _, err := Search("This is the email body", "", 0, testRuns)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, len(summaries), testRuns, "search results expected")
|
||||
}
|
||||
|
||||
func TestSearchDelete100(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
|
||||
tenantID = config.DBTenantID(tenantID)
|
||||
|
||||
t.Log("Testing search delete of 100 messages")
|
||||
for i := 0; i < 100; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
setup(tenantID)
|
||||
|
||||
if tenantID == "" {
|
||||
t.Log("Testing search delete of 100 messages")
|
||||
} else {
|
||||
t.Logf("Testing search delete of 100 messages (tenant %s)", tenantID)
|
||||
}
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
_, total, err := Search("from:sender@example.com", "", 0, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
|
||||
assertEqual(t, total, 100, "100 search results expected")
|
||||
|
||||
if err := DeleteSearch("from:sender@example.com", ""); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
_, total, err = Search("from:sender@example.com", "", 0, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, total, 0, "0 search results expected")
|
||||
|
||||
Close()
|
||||
}
|
||||
|
||||
_, total, err := Search("from:sender@example.com", "", 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, total, 100, "100 search results expected")
|
||||
|
||||
if err := DeleteSearch("from:sender@example.com", ""); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
_, total, err = Search("from:sender@example.com", "", 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, total, 0, "0 search results expected")
|
||||
}
|
||||
|
||||
func TestSearchDelete1100(t *testing.T) {
|
||||
setup()
|
||||
setup("")
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing search delete of 1100 messages")
|
||||
@@ -143,7 +164,7 @@ func TestSearchDelete1100(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
_, total, err := Search("from:sender@example.com", "", 0, 100)
|
||||
_, total, err := Search("from:sender@example.com", "", 0, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -156,7 +177,7 @@ func TestSearchDelete1100(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
_, total, err = Search("from:sender@example.com", "", 0, 100)
|
||||
_, total, err = Search("from:sender@example.com", "", 0, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
|
||||
@@ -3,8 +3,6 @@ package storage
|
||||
import (
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
// Message data excluding physical attachments
|
||||
@@ -114,21 +112,6 @@ type DBMailSummary struct {
|
||||
ReplyTo []*mail.Address
|
||||
}
|
||||
|
||||
// AttachmentSummary returns a summary of the attachment without any binary data
|
||||
func AttachmentSummary(a *enmime.Part) Attachment {
|
||||
o := Attachment{}
|
||||
o.PartID = a.PartID
|
||||
o.FileName = a.FileName
|
||||
if o.FileName == "" {
|
||||
o.FileName = a.ContentID
|
||||
}
|
||||
o.ContentType = a.ContentType
|
||||
o.ContentID = a.ContentID
|
||||
o.Size = float64(len(a.Content))
|
||||
|
||||
return o
|
||||
}
|
||||
|
||||
// ListUnsubscribe contains a summary of List-Unsubscribe & List-Unsubscribe-Post headers
|
||||
// including validation of the link structure
|
||||
type ListUnsubscribe struct {
|
||||
|
||||
@@ -13,9 +13,12 @@ import (
|
||||
|
||||
// TagFilter struct
|
||||
type TagFilter struct {
|
||||
// Match is the user-defined match
|
||||
Match string
|
||||
SQL *sqlf.Stmt
|
||||
Tags []string
|
||||
// SQL represents the SQL equivalent of Match
|
||||
SQL *sqlf.Stmt
|
||||
// Tags to add on match
|
||||
Tags []string
|
||||
}
|
||||
|
||||
var tagFilters = []TagFilter{}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
@@ -21,7 +23,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 +32,7 @@ func SetMessageTags(id string, tags []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
tagNames := []string{}
|
||||
currentTags := getMessageTags(id)
|
||||
origTagCount := len(currentTags)
|
||||
|
||||
@@ -38,9 +41,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 {
|
||||
@@ -48,43 +54,52 @@ 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
|
||||
if err := deleteMessageTag(id, t); err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
d := struct {
|
||||
ID string
|
||||
Tags []string
|
||||
}{ID: id, Tags: applyTags}
|
||||
|
||||
websockets.Broadcast("update", d)
|
||||
|
||||
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 +108,8 @@ 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,20 +117,20 @@ func AddMessageTag(id, name string) error {
|
||||
Set("Name", name).
|
||||
ExecAndClose(context.TODO(), db); err != nil {
|
||||
addTagMutex.Unlock()
|
||||
return err
|
||||
return name, err
|
||||
}
|
||||
|
||||
addTagMutex.Unlock()
|
||||
|
||||
// add tag to the message
|
||||
return AddMessageTag(id, name)
|
||||
return addMessageTag(id, name)
|
||||
}
|
||||
|
||||
// DeleteMessageTag deleted a tag from a message
|
||||
func DeleteMessageTag(id, name string) error {
|
||||
// DeleteMessageTag deletes a tag from a message
|
||||
func deleteMessageTag(id, name string) error {
|
||||
if _, err := sqlf.DeleteFrom(tenant("message_tags")).
|
||||
Where(tenant("message_tags.ID")+" = ?", id).
|
||||
Where(tenant("message_tags.Key")+` IN (SELECT Key FROM `+tenant("message_tags")+` LEFT JOIN tags ON `+tenant("TagID")+"="+tenant("tags.ID")+` WHERE Name = ?)`, name).
|
||||
Where(tenant("message_tags.Key")+` IN (SELECT Key FROM `+tenant("message_tags")+` LEFT JOIN `+tenant("tags")+` ON TagID=`+tenant("tags.ID")+` WHERE Name = ?)`, name).
|
||||
ExecAndClose(context.TODO(), db); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -166,7 +182,6 @@ func GetAllTagsCount() map[string]int64 {
|
||||
OrderBy("Name").
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
tags[name] = total
|
||||
// tags = append(tags, name)
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
@@ -174,6 +189,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")).
|
||||
|
||||
@@ -4,127 +4,140 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
)
|
||||
|
||||
func TestTags(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing tags")
|
||||
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
|
||||
tenantID = config.DBTenantID(tenantID)
|
||||
|
||||
ids := []string{}
|
||||
setup(tenantID)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
if tenantID == "" {
|
||||
t.Log("Testing tags")
|
||||
} else {
|
||||
t.Logf("Testing tags (tenant %s)", tenantID)
|
||||
}
|
||||
|
||||
ids := []string{}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
id, err := Store(&testMimeEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
if _, err := SetMessageTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
message, err := GetMessage(ids[i])
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
if len(message.Tags) != 1 || message.Tags[0] != fmt.Sprintf("Tag-%d", i) {
|
||||
t.Fatal("Message tags do not match")
|
||||
}
|
||||
}
|
||||
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// test 20 tags
|
||||
id, err := Store(&testMimeEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
if err := SetMessageTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil {
|
||||
newTags := []string{}
|
||||
for i := 0; i < 20; i++ {
|
||||
// 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 {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
returnedTags := getMessageTags(id)
|
||||
assertEqual(t, strings.Join(newTags, "|"), strings.Join(returnedTags, "|"), "Message tags do not match")
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
message, err := GetMessage(ids[i])
|
||||
// remove first tag
|
||||
if err := deleteMessageTag(id, newTags[0]); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, strings.Join(newTags[1:], "|"), strings.Join(returnedTags, "|"), "Message tags do not match after deleting 1")
|
||||
|
||||
// remove all tags
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
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 {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "Duplicate Tag", strings.Join(returnedTags, "|"), "Message tags should be duplicated")
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// apply tag with invalid characters
|
||||
if _, err := SetMessageTags(id, []string{"Dirty! \"Tag\""}); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "Dirty Tag", strings.Join(returnedTags, "|"), "Dirty message tag did not clean as expected")
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// Check deleted message tags also prune the tags database
|
||||
allTags := GetAllTags()
|
||||
assertEqual(t, "", strings.Join(allTags, "|"), "Tags did not delete as expected")
|
||||
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// test 20 tags
|
||||
id, err = Store(&testTagEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
if len(message.Tags) != 1 || message.Tags[0] != fmt.Sprintf("Tag-%d", i) {
|
||||
t.Fatal("Message tags do not match")
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "BccTag|CcTag|FromFag|ToTag|X-tag1|X-tag2", strings.Join(returnedTags, "|"), "Tags not detected correctly")
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
Close()
|
||||
}
|
||||
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// test 20 tags
|
||||
id, err := Store(&testMimeEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
newTags := []string{}
|
||||
for i := 0; i < 20; i++ {
|
||||
// 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 {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags := getMessageTags(id)
|
||||
assertEqual(t, strings.Join(newTags, "|"), strings.Join(returnedTags, "|"), "Message tags do not match")
|
||||
|
||||
// remove first tag
|
||||
if err := DeleteMessageTag(id, newTags[0]); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, strings.Join(newTags[1:], "|"), strings.Join(returnedTags, "|"), "Message tags do not match after deleting 1")
|
||||
|
||||
// remove all tags
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
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 {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "Duplicate Tag", strings.Join(returnedTags, "|"), "Message tags should be duplicated")
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// apply tag with invalid characters
|
||||
if err := SetMessageTags(id, []string{"Dirty! \"Tag\""}); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "Dirty Tag", strings.Join(returnedTags, "|"), "Dirty message tag did not clean as expected")
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// Check deleted message tags also prune the tags database
|
||||
allTags := GetAllTags()
|
||||
assertEqual(t, "", strings.Join(allTags, "|"), "Tags did not delete as expected")
|
||||
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// test 20 tags
|
||||
id, err = Store(&testTagEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "BccTag|CcTag|FromFag|ToTag|X-tag1|X-tag2", strings.Join(returnedTags, "|"), "Tags not detected correctly")
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,11 @@ var (
|
||||
testRuns = 100
|
||||
)
|
||||
|
||||
func setup() {
|
||||
func setup(tenantID string) {
|
||||
logger.NoLogging = true
|
||||
config.MaxMessages = 0
|
||||
config.Database = os.Getenv("MP_DATABASE")
|
||||
config.TenantID = config.DBTenantID(tenantID)
|
||||
|
||||
if err := InitDB(); err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -2,6 +2,7 @@ package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -10,17 +11,28 @@ func Plural(total int, singular, plural string) string {
|
||||
if total == 1 {
|
||||
return fmt.Sprintf("%d %s", total, singular)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d %s", total, plural)
|
||||
}
|
||||
|
||||
// InArray tests if a string is within an array. It is not case sensitive.
|
||||
func InArray(k string, arr []string) bool {
|
||||
k = strings.ToLower(k)
|
||||
for _, v := range arr {
|
||||
if strings.ToLower(v) == k {
|
||||
if strings.EqualFold(v, k) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -71,5 +71,6 @@ func Unzip(src string, dest string) ([]string, error) {
|
||||
return filenames, err
|
||||
}
|
||||
}
|
||||
|
||||
return filenames, nil
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ var (
|
||||
// AllowPrereleases defines whether pre-releases may be included
|
||||
AllowPrereleases = false
|
||||
|
||||
// temporary directory
|
||||
tempDir string
|
||||
)
|
||||
|
||||
@@ -329,7 +330,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
|
||||
|
||||
1571
package-lock.json
generated
1571
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,9 @@
|
||||
"bootstrap5-tags": "^1.6.1",
|
||||
"color-hash": "^2.0.2",
|
||||
"dayjs": "^1.11.10",
|
||||
"dompurify": "^3.1.6",
|
||||
"ical.js": "^2.0.1",
|
||||
"mitt": "^3.0.1",
|
||||
"modern-screenshot": "^4.4.30",
|
||||
"prismjs": "^1.29.0",
|
||||
"rapidoc": "^9.3.4",
|
||||
@@ -28,7 +31,7 @@
|
||||
"@types/bootstrap": "^5.2.7",
|
||||
"@types/tinycon": "^0.6.3",
|
||||
"@vue/compiler-sfc": "^3.2.37",
|
||||
"esbuild": "^0.20.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"esbuild-plugin-vue-next": "^0.1.4",
|
||||
"esbuild-sass-plugin": "^3.0.0"
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"net/smtp"
|
||||
"os"
|
||||
"os/user"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
@@ -34,7 +35,6 @@ var (
|
||||
SMTPAddr = "localhost:1025"
|
||||
// FromAddr email address
|
||||
FromAddr string
|
||||
|
||||
// UseB - used to set from `-bs`
|
||||
UseB bool
|
||||
// UseS - used to set from `-bs`
|
||||
@@ -42,15 +42,19 @@ var (
|
||||
)
|
||||
|
||||
func init() {
|
||||
// ensure only valid characters are used, ie: windows
|
||||
re := regexp.MustCompile(`[^a-zA-Z\-\.\_]`)
|
||||
host, err := os.Hostname()
|
||||
if err != nil {
|
||||
host = "localhost"
|
||||
} else {
|
||||
host = re.ReplaceAllString(host, "-")
|
||||
}
|
||||
|
||||
username := "nobody"
|
||||
user, err := user.Current()
|
||||
if err == nil && user != nil && len(user.Username) > 0 {
|
||||
username = user.Username
|
||||
username = re.ReplaceAllString(user.Username, "-")
|
||||
}
|
||||
|
||||
if FromAddr == "" {
|
||||
@@ -62,7 +66,7 @@ func init() {
|
||||
func Run() {
|
||||
var recipients []string
|
||||
|
||||
// defaults from envars if provided
|
||||
// defaults from env vars if provided
|
||||
if len(os.Getenv("MP_SENDMAIL_SMTP_ADDR")) > 0 {
|
||||
SMTPAddr = os.Getenv("MP_SENDMAIL_SMTP_ADDR")
|
||||
}
|
||||
|
||||
@@ -9,18 +9,16 @@ import (
|
||||
"net/mail"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/araddon/dateparse"
|
||||
"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"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
// GetMessages returns a paginated list of messages as JSON
|
||||
@@ -53,9 +51,9 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
// Responses:
|
||||
// 200: MessagesSummaryResponse
|
||||
// default: ErrorResponse
|
||||
start, limit := getStartLimit(r)
|
||||
start, beforeTS, limit := getStartLimit(r)
|
||||
|
||||
messages, err := storage.List(start, limit)
|
||||
messages, err := storage.List(start, beforeTS, limit)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
@@ -73,9 +71,10 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
res.Tags = stats.Tags
|
||||
res.MessagesCount = stats.Total
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Search returns the latest messages as JSON
|
||||
@@ -124,9 +123,9 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
start, limit := getStartLimit(r)
|
||||
start, beforeTS, limit := getStartLimit(r)
|
||||
|
||||
messages, results, err := storage.Search(search, r.URL.Query().Get("tz"), start, limit)
|
||||
messages, results, err := storage.Search(search, r.URL.Query().Get("tz"), start, beforeTS, limit)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
@@ -144,9 +143,10 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
res.Unread = stats.Unread
|
||||
res.Tags = stats.Tags
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteSearch will delete all messages matching a search
|
||||
@@ -238,9 +238,10 @@ func GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(msg)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
if err := json.NewEncoder(w).Encode(msg); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadAttachment (method: GET) returns the attachment data
|
||||
@@ -347,14 +348,10 @@ func GetHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
bytes, err := json.Marshal(m.Header)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
if err := json.NewEncoder(w).Encode(m.Header); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadRaw (method: GET) returns the full email source as plain text
|
||||
@@ -448,7 +445,7 @@ func DeleteMessages(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/plain")
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
@@ -524,217 +521,11 @@ 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
|
||||
|
||||
tags := storage.GetAllTags()
|
||||
|
||||
data, err := json.Marshal(tags)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// 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
|
||||
//
|
||||
// # HTML check (beta)
|
||||
// # HTML check
|
||||
//
|
||||
// Returns the summary of the message HTML checker.
|
||||
//
|
||||
@@ -760,12 +551,22 @@ func HTMLCheck(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
raw, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
e := bytes.NewReader(raw)
|
||||
|
||||
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
|
||||
|
||||
msg, err := parser.ReadEnvelope(e)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if msg.HTML == "" {
|
||||
httpError(w, "message does not contain HTML")
|
||||
return
|
||||
@@ -777,16 +578,17 @@ func HTMLCheck(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(checks)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
if err := json.NewEncoder(w).Encode(checks); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// LinkCheck returns a summary of links in the email
|
||||
func LinkCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/link-check Other LinkCheck
|
||||
//
|
||||
// # Link check (beta)
|
||||
// # Link check
|
||||
//
|
||||
// Returns the summary of the message Link checker.
|
||||
//
|
||||
@@ -799,6 +601,11 @@ func LinkCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// 200: LinkCheckResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
if config.DemoMode {
|
||||
httpError(w, "this functionality has been disabled for demonstration purposes")
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
@@ -827,21 +634,19 @@ func LinkCheck(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(summary)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
if err := json.NewEncoder(w).Encode(summary); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// SpamAssassinCheck returns a summary of SpamAssassin results (if enabled)
|
||||
func SpamAssassinCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/sa-check Other SpamAssassinCheck
|
||||
//
|
||||
// # SpamAssassin check (beta)
|
||||
// # SpamAssassin check
|
||||
//
|
||||
// Returns the SpamAssassin (if enabled) summary of the message.
|
||||
//
|
||||
// NOTE: This feature is currently in beta and is documented for reference only.
|
||||
// Please do not integrate with it (yet) as there may be changes.
|
||||
// Returns the SpamAssassin summary (if enabled) of the message.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
@@ -877,9 +682,10 @@ func SpamAssassinCheck(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(summary)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
if err := json.NewEncoder(w).Encode(summary); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// FourOFour returns a basic 404 message
|
||||
@@ -908,15 +714,18 @@ func httpJSONError(w http.ResponseWriter, msg string) {
|
||||
e := JSONErrorMessage{
|
||||
Error: msg,
|
||||
}
|
||||
bytes, _ := json.Marshal(e)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
if err := json.NewEncoder(w).Encode(e); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Get the start and limit based on query params. Defaults to 0, 50
|
||||
func getStartLimit(req *http.Request) (start int, limit int) {
|
||||
func getStartLimit(req *http.Request) (start int, beforeTS int64, limit int) {
|
||||
start = 0
|
||||
limit = 50
|
||||
beforeTS = 0 // timestamp
|
||||
|
||||
s := req.URL.Query().Get("start")
|
||||
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
||||
@@ -928,7 +737,17 @@ func getStartLimit(req *http.Request) (start int, limit int) {
|
||||
limit = n
|
||||
}
|
||||
|
||||
return start, limit
|
||||
b := req.URL.Query().Get("before")
|
||||
if b != "" {
|
||||
t, err := dateparse.ParseLocal(b)
|
||||
if err != nil {
|
||||
logger.Log().Warnf("ignoring invalid before: date \"%s\"", b)
|
||||
} else {
|
||||
beforeTS = t.UnixMilli()
|
||||
}
|
||||
}
|
||||
|
||||
return start, beforeTS, limit
|
||||
}
|
||||
|
||||
// GetOptions returns a blank response
|
||||
|
||||
@@ -24,10 +24,8 @@ func AppInfo(w http.ResponseWriter, _ *http.Request) {
|
||||
// 200: InfoResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
info := stats.Load()
|
||||
|
||||
bytes, _ := json.Marshal(info)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
if err := json.NewEncoder(w).Encode(stats.Load()); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
172
server/apiv1/release.go
Normal file
172
server/apiv1/release.go
Normal file
@@ -0,0 +1,172 @@
|
||||
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
|
||||
|
||||
if config.DemoMode {
|
||||
httpError(w, "this functionality has been disabled for demonstration purposes")
|
||||
return
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/smtpd"
|
||||
"github.com/jhillyerd/enmime"
|
||||
@@ -141,6 +142,11 @@ func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 200: sendMessageResponse
|
||||
// default: jsonErrorResponse
|
||||
|
||||
if config.DemoMode {
|
||||
httpJSONError(w, "this functionality has been disabled for demonstration purposes")
|
||||
return
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
data := SendRequest{}
|
||||
@@ -157,10 +163,10 @@ func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(SendMessageConfirmation{ID: id})
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
if err := json.NewEncoder(w).Encode(SendMessageConfirmation{ID: id}); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Send will validate the message structure and attempt to send to Mailpit.
|
||||
|
||||
@@ -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
171
server/apiv1/tags.go
Normal 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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -65,8 +71,8 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
conf.SpamAssassin = config.EnableSpamAssassin != ""
|
||||
conf.DuplicatesIgnored = config.IgnoreDuplicateIDs
|
||||
|
||||
bytes, _ := json.Marshal(conf)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
if err := json.NewEncoder(w).Encode(conf); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,13 @@ func RedirectToLatestMessage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
if search != "" {
|
||||
messages, _, err = storage.Search(search, "", 0, 1)
|
||||
messages, _, err = storage.Search(search, "", 0, 0, 1)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
messages, err = storage.List(0, 1)
|
||||
messages, err = storage.List(0, 0, 1)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
)
|
||||
|
||||
func authUser(username, password string) bool {
|
||||
@@ -19,6 +20,11 @@ func authUser(username, password string) bool {
|
||||
func sendResponse(c net.Conn, m string) {
|
||||
fmt.Fprintf(c, "%s\r\n", m)
|
||||
logger.Log().Debugf("[pop3] response: %s", m)
|
||||
|
||||
if strings.HasPrefix(m, "-ERR ") {
|
||||
sub, _ := strings.CutPrefix(m, "-ERR ")
|
||||
websockets.BroadCastClientError("error", "pop3", c.RemoteAddr().String(), sub)
|
||||
}
|
||||
}
|
||||
|
||||
// Send a response without debug logging (for data)
|
||||
@@ -26,9 +32,10 @@ func sendData(c net.Conn, m string) {
|
||||
fmt.Fprintf(c, "%s\r\n", m)
|
||||
}
|
||||
|
||||
// Get the latest 100 messages
|
||||
func getMessages() ([]message, error) {
|
||||
messages := []message{}
|
||||
list, err := storage.List(0, 100)
|
||||
list, err := storage.List(0, 0, 100)
|
||||
if err != nil {
|
||||
return messages, err
|
||||
}
|
||||
@@ -72,5 +79,6 @@ 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")
|
||||
}
|
||||
|
||||
@@ -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, string(raw))
|
||||
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
365
server/pop3/pop3_test.go
Normal 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
319
server/pop3/server.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/axllent/mailpit/server/pop3"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
)
|
||||
|
||||
//go:embed ui
|
||||
@@ -75,11 +76,11 @@ func Listen() {
|
||||
}
|
||||
|
||||
// UI shortcut
|
||||
r.HandleFunc(config.Webroot+"view/latest", handlers.RedirectToLatestMessage).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"view/latest", middleWareFunc(handlers.RedirectToLatestMessage)).Methods("GET")
|
||||
|
||||
// frontend testing
|
||||
r.HandleFunc(config.Webroot+"view/{id}.html", handlers.GetMessageHTML).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"view/{id}.txt", handlers.GetMessageText).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"view/{id}.html", middleWareFunc(handlers.GetMessageHTML)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"view/{id}.txt", middleWareFunc(handlers.GetMessageText)).Methods("GET")
|
||||
|
||||
// web UI via virtual index.html
|
||||
r.PathPrefix(config.Webroot + "view/").Handler(middleWareFunc(index)).Methods("GET")
|
||||
@@ -132,6 +133,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")
|
||||
@@ -177,7 +180,21 @@ func (w gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
|
||||
|
||||
// generate a new random nonce on every request
|
||||
randomNonce := shortuuid.New()
|
||||
// header used to pass nonce through to function
|
||||
r.Header.Set("mp-nonce", randomNonce)
|
||||
|
||||
// Prevent JavaScript XSS by adding a nonce for script-src
|
||||
cspHeader := strings.Replace(
|
||||
config.ContentSecurityPolicy,
|
||||
"script-src 'self';",
|
||||
fmt.Sprintf("script-src 'nonce-%s';", randomNonce),
|
||||
1,
|
||||
)
|
||||
|
||||
w.Header().Set("Content-Security-Policy", cspHeader)
|
||||
|
||||
if AccessControlAllowOrigin != "" && strings.HasPrefix(r.RequestURI, config.Webroot+"api/") {
|
||||
w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin)
|
||||
@@ -203,6 +220,7 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
fn(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
gz := gzip.NewWriter(w)
|
||||
defer gz.Close()
|
||||
@@ -279,7 +297,7 @@ func swaggerBasePath(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
|
||||
// Just returns the default HTML template
|
||||
func index(w http.ResponseWriter, _ *http.Request) {
|
||||
func index(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var h = `<!DOCTYPE html>
|
||||
<html lang="en" class="h-100">
|
||||
@@ -296,10 +314,12 @@ func index(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
<body class="h-100">
|
||||
<div class="container-fluid h-100 d-flex flex-column" id="app" data-webroot="{{ .Webroot }}" data-version="{{ .Version }}">
|
||||
<noscript>You require JavaScript to use this app.</noscript>
|
||||
<noscript class="alert alert-warning position-absolute top-50 start-50 translate-middle">
|
||||
You need a browser with JavaScript support to use Mailpit
|
||||
</noscript>
|
||||
</div>
|
||||
|
||||
<script src="{{ .Webroot }}dist/app.js?{{ .Version }}"></script>
|
||||
<script src="{{ .Webroot }}dist/app.js?{{ .Version }}" nonce="{{ .Nonce }}"></script>
|
||||
</body>
|
||||
|
||||
</html>`
|
||||
@@ -312,9 +332,11 @@ func index(w http.ResponseWriter, _ *http.Request) {
|
||||
data := struct {
|
||||
Webroot string
|
||||
Version string
|
||||
Nonce string
|
||||
}{
|
||||
Webroot: config.Webroot,
|
||||
Version: config.Version,
|
||||
Nonce: r.Header.Get("mp-nonce"),
|
||||
}
|
||||
|
||||
buff := new(bytes.Buffer)
|
||||
@@ -324,8 +346,6 @@ func index(w http.ResponseWriter, _ *http.Request) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
buff.Bytes()
|
||||
|
||||
w.Header().Add("Content-Type", "text/html")
|
||||
_, _ = w.Write(buff.Bytes())
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/stats"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
"github.com/mhale/smtpd"
|
||||
)
|
||||
@@ -21,13 +22,14 @@ import (
|
||||
var (
|
||||
// DisableReverseDNS allows rDNS to be disabled
|
||||
DisableReverseDNS bool
|
||||
|
||||
warningResponse = regexp.MustCompile(`^4\d\d `)
|
||||
errorResponse = regexp.MustCompile(`^5\d\d `)
|
||||
)
|
||||
|
||||
// MailHandler handles the incoming message to store in the database
|
||||
func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
|
||||
_, err := Store(origin, from, to, data)
|
||||
|
||||
return err
|
||||
func mailHandler(origin net.Addr, from string, to []string, data []byte) (string, error) {
|
||||
return Store(origin, from, to, data)
|
||||
}
|
||||
|
||||
// Store will attempt to save a message to the database
|
||||
@@ -40,7 +42,7 @@ func Store(origin net.Addr, from string, to []string, data []byte) (string, erro
|
||||
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[smtpd] error parsing message: %s", err.Error())
|
||||
logger.Log().Warnf("[smtpd] error parsing message: %s", err.Error())
|
||||
stats.LogSMTPRejected()
|
||||
return "", err
|
||||
}
|
||||
@@ -212,10 +214,20 @@ func Listen() error {
|
||||
return listenAndServe(config.SMTPListen, mailHandler, authHandler)
|
||||
}
|
||||
|
||||
func listenAndServe(addr string, handler smtpd.Handler, authHandler smtpd.AuthHandler) error {
|
||||
// Translate the smtpd verb from READ/WRITE
|
||||
func verbLogTranslator(verb string) string {
|
||||
if verb == "READ" {
|
||||
return "received"
|
||||
}
|
||||
|
||||
return "response"
|
||||
}
|
||||
|
||||
func listenAndServe(addr string, handler smtpd.MsgIDHandler, authHandler smtpd.AuthHandler) error {
|
||||
smtpd.Debug = true // to enable Mailpit logging
|
||||
srv := &smtpd.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
MsgIDHandler: handler,
|
||||
HandlerRcpt: handlerRcpt,
|
||||
Appname: "Mailpit",
|
||||
Hostname: "",
|
||||
@@ -223,6 +235,24 @@ func listenAndServe(addr string, handler smtpd.Handler, authHandler smtpd.AuthHa
|
||||
AuthRequired: false,
|
||||
MaxRecipients: config.SMTPMaxRecipients,
|
||||
DisableReverseDNS: DisableReverseDNS,
|
||||
LogRead: func(remoteIP, verb, line string) {
|
||||
logger.Log().Debugf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
|
||||
},
|
||||
LogWrite: func(remoteIP, verb, line string) {
|
||||
if warningResponse.MatchString(line) {
|
||||
logger.Log().Warnf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
|
||||
websockets.BroadCastClientError("warning", "smtpd", remoteIP, line)
|
||||
} else if errorResponse.MatchString(line) {
|
||||
logger.Log().Errorf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
|
||||
websockets.BroadCastClientError("error", "smtpd", remoteIP, line)
|
||||
} else {
|
||||
logger.Log().Debugf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
if config.Label != "" {
|
||||
srv.Appname = fmt.Sprintf("Mailpit (%s)", config.Label)
|
||||
}
|
||||
|
||||
if config.SMTPAuthAllowInsecure {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { createApp } from 'vue'
|
||||
import mitt from 'mitt';
|
||||
|
||||
import './assets/styles.scss'
|
||||
import 'bootstrap-icons/font/bootstrap-icons.scss'
|
||||
import 'bootstrap'
|
||||
import 'vue-css-donut-chart/src/styles/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Global event bus used to subscribe to websocket events
|
||||
// such as message deletes, updates & truncation.
|
||||
const eventBus = mitt()
|
||||
app.provide('eventBus', eventBus)
|
||||
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -91,44 +91,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.about-mailpit {
|
||||
@include media-breakpoint-down(md) {
|
||||
width: var(--bs-offcanvas-width);
|
||||
margin-left: -1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
.subject {
|
||||
color: $text-muted;
|
||||
|
||||
b {
|
||||
color: $list-group-color;
|
||||
}
|
||||
|
||||
small {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&.read {
|
||||
color: $text-muted;
|
||||
|
||||
b {
|
||||
opacity: 0.7;
|
||||
font-weight: normal;
|
||||
color: $list-group-color;
|
||||
}
|
||||
|
||||
small {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
&.selected {
|
||||
background: var(--bs-primary-bg-subtle);
|
||||
}
|
||||
}
|
||||
|
||||
.text-spaces-nowrap {
|
||||
white-space: pre;
|
||||
}
|
||||
@@ -266,8 +228,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item.message:first-child {
|
||||
border-top: 0;
|
||||
#message-page {
|
||||
.list-group-item.message:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.message {
|
||||
.subject {
|
||||
color: $text-muted;
|
||||
|
||||
b {
|
||||
color: $list-group-color;
|
||||
}
|
||||
|
||||
small {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&.read {
|
||||
color: $text-muted;
|
||||
|
||||
b {
|
||||
color: $list-group-color;
|
||||
}
|
||||
}
|
||||
&.selected {
|
||||
background: var(--bs-primary-bg-subtle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body.blur {
|
||||
@@ -320,6 +309,18 @@ body.blur {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message {
|
||||
&.read {
|
||||
> div {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
b {
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#message-view {
|
||||
.form-control.dropdown {
|
||||
padding: 0;
|
||||
|
||||
@@ -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
|
||||
@@ -56,14 +54,13 @@ export default {
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div
|
||||
class="position-fixed bg-body bottom-0 ms-n1 py-2 text-muted small col-xl-2 col-md-3 pe-3 z-3 about-mailpit">
|
||||
<button class="text-muted btn btn-sm" v-on:click="loadInfo">
|
||||
<div class="bg-body ms-sm-n1 me-sm-n1 py-2 text-muted small about-mailpit">
|
||||
<button class="text-muted btn btn-sm ps-0" v-on:click="loadInfo()">
|
||||
<i class="bi bi-info-circle-fill me-1"></i>
|
||||
About
|
||||
</button>
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary float-end me-2" data-bs-toggle="modal"
|
||||
<button class="btn btn-sm btn-outline-secondary float-end" data-bs-toggle="modal"
|
||||
data-bs-target="#SettingsModal" title="Mailpit UI settings">
|
||||
<i class="bi bi-gear-fill"></i>
|
||||
</button>
|
||||
@@ -93,16 +90,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">
|
||||
@@ -150,7 +151,7 @@ export default {
|
||||
<div class="card-header h4">
|
||||
Runtime statistics
|
||||
<button class="btn btn-sm btn-outline-secondary float-end"
|
||||
v-on:click="loadInfo">
|
||||
v-on:click="loadInfo()">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
@@ -180,7 +181,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 +248,6 @@ export default {
|
||||
|
||||
<Settings />
|
||||
</template>
|
||||
|
||||
|
||||
<AjaxLoader :loading="loading" />
|
||||
</template>
|
||||
|
||||
119
server/ui-src/components/EditTags.vue
Normal file
119
server/ui-src/components/EditTags.vue
Normal 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>
|
||||
@@ -39,10 +39,9 @@ export default {
|
||||
}
|
||||
|
||||
this.iconProcessing = true
|
||||
let self = this
|
||||
|
||||
window.setTimeout(() => {
|
||||
self.icoUpdate()
|
||||
this.icoUpdate()
|
||||
}, this.iconTimeout)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,33 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
let relativeTime = require('dayjs/plugin/relativeTime')
|
||||
created() {
|
||||
const relativeTime = require('dayjs/plugin/relativeTime')
|
||||
dayjs.extend(relativeTime)
|
||||
},
|
||||
|
||||
mounted() {
|
||||
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 +50,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 +66,7 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
selectRange: function (e, id) {
|
||||
selectRange(e, id) {
|
||||
e.preventDefault()
|
||||
|
||||
let selecting = false
|
||||
@@ -99,6 +100,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,19 +132,17 @@ 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) }}
|
||||
To: {{ getPrimaryEmailTo(message) }}
|
||||
<span v-if="message.To && message.To.length > 1">
|
||||
[+{{ message.To.length - 1 }}]
|
||||
</span>
|
||||
@@ -143,7 +156,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 }}
|
||||
|
||||
@@ -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,13 @@ export default {
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div class="list-group my-2">
|
||||
<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label">
|
||||
<div class="text-truncate fw-normal">
|
||||
{{ mailbox.uiConfig.Label }}
|
||||
</div>
|
||||
</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 +123,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 +132,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>
|
||||
|
||||
@@ -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,13 @@ export default {
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div class="list-group my-2">
|
||||
<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label">
|
||||
<div class="text-truncate fw-normal">
|
||||
{{ mailbox.uiConfig.Label }}
|
||||
</div>
|
||||
</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 +81,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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -6,44 +6,28 @@ import { pagination } from '../stores/pagination'
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
emits: ['loadMessages'],
|
||||
|
||||
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) {
|
||||
return false
|
||||
}
|
||||
|
||||
let re = new RegExp(`(^|\\s)tag:"?${tag}"?($|\\s)`, 'i')
|
||||
let re = new RegExp(`(^|\\s)tag:("${tag}"|${tag}\\b)`, 'i')
|
||||
return query.match(re)
|
||||
},
|
||||
|
||||
// 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 +51,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>
|
||||
@@ -77,10 +80,15 @@ export default {
|
||||
<template>
|
||||
<template v-if="mailbox.tags && mailbox.tags.length">
|
||||
<div class="mt-4 text-muted">
|
||||
<button class="btn btn-sm dropdown-toggle ms-n1" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<button class="btn btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
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>
|
||||
@@ -90,9 +98,9 @@ export default {
|
||||
</li>
|
||||
</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)"
|
||||
<div class="list-group mt-1 mb-2">
|
||||
<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>
|
||||
|
||||
@@ -7,6 +7,9 @@ import { pagination } from '../stores/pagination'
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination,
|
||||
@@ -14,33 +17,38 @@ 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,
|
||||
clientErrors: [], // errors received via websocket
|
||||
}
|
||||
},
|
||||
|
||||
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
|
||||
&& ("Notification" in window && Notification.permission !== "denied")
|
||||
mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission == "granted"
|
||||
|
||||
this.errorNotificationCron()
|
||||
},
|
||||
|
||||
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)
|
||||
@@ -50,18 +58,7 @@ export default {
|
||||
|
||||
// new messages
|
||||
if (response.Type == "new" && response.Data) {
|
||||
if (!mailbox.searching) {
|
||||
if (pagination.start < 1) {
|
||||
// push results directly into first page
|
||||
mailbox.messages.unshift(response.Data)
|
||||
if (mailbox.messages.length > pagination.limit) {
|
||||
mailbox.messages.pop()
|
||||
}
|
||||
} else {
|
||||
// update pagination offset
|
||||
pagination.start++
|
||||
}
|
||||
}
|
||||
this.eventBus.emit("new", response.Data)
|
||||
|
||||
for (let i in response.Data.Tags) {
|
||||
if (mailbox.tags.findIndex(e => { return e.toLowerCase() === response.Data.Tags[i].toLowerCase() }) < 0) {
|
||||
@@ -73,46 +70,84 @@ 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
|
||||
window.scrollInPlace = true
|
||||
mailbox.refresh = true // trigger refresh
|
||||
window.setTimeout(() => { mailbox.refresh = false }, 500)
|
||||
this.eventBus.emit("prune");
|
||||
} else if (response.Type == "stats" && response.Data) {
|
||||
// refresh mailbox stats
|
||||
mailbox.total = response.Data.Total
|
||||
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()
|
||||
}
|
||||
} else if (response.Type == "delete" && response.Data) {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("delete", response.Data)
|
||||
} else if (response.Type == "update" && response.Data) {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("update", response.Data)
|
||||
} else if (response.Type == "truncate") {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("truncate")
|
||||
} else if (response.Type == "error") {
|
||||
// broadcast for components
|
||||
this.addClientError(response.Data)
|
||||
}
|
||||
}
|
||||
|
||||
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 +156,19 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
browserNotify: function (title, message) {
|
||||
socketBreakReset() {
|
||||
window.setTimeout(() => {
|
||||
this.socketBreaks = 0
|
||||
this.socketBreakReset()
|
||||
}, 15000)
|
||||
},
|
||||
|
||||
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 +177,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,29 +185,59 @@ 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()
|
||||
}
|
||||
},
|
||||
|
||||
addClientError(d) {
|
||||
d.expire = Date.now() + 5000 // expire after 5s
|
||||
this.clientErrors.push(d)
|
||||
},
|
||||
|
||||
errorNotificationCron() {
|
||||
window.setTimeout(() => {
|
||||
this.clientErrors.forEach((err, idx) => {
|
||||
if (err.expire < Date.now()) {
|
||||
this.clientErrors.splice(idx, 1)
|
||||
}
|
||||
})
|
||||
this.errorNotificationCron()
|
||||
}, 1000)
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div v-for="error in clientErrors" class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header">
|
||||
<svg class="bd-placeholder-img rounded me-2" width="20" height="20" xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true" preserveAspectRatio="xMidYMid slice" focusable="false">
|
||||
<rect width="100%" height="100%" :fill="error.Level == 'warning' ? '#ffc107' : '#dc3545'"></rect>
|
||||
</svg>
|
||||
<strong class="me-auto">{{ error.Type }}</strong>
|
||||
<small class="text-body-secondary">{{ error.IP }}</small>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
{{ error.Message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header" v-if="toastMessage">
|
||||
<i class="bi bi-envelope-exclamation-fill me-2"></i>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('/')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
<script>
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import ICAL from "ical.js"
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -8,16 +9,72 @@ export default {
|
||||
attachments: Object
|
||||
},
|
||||
|
||||
mixins: [commonMixins]
|
||||
mixins: [commonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
ical: false
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
openAttachment(part, e) {
|
||||
let filename = part.FileName
|
||||
let contentType = part.ContentType
|
||||
let href = this.resolve('/api/v1/message/' + this.message.ID + '/part/' + part.PartID)
|
||||
if (filename.match(/\.ics$/i) || contentType == 'text/calendar') {
|
||||
e.preventDefault()
|
||||
|
||||
this.get(href, null, (response) => {
|
||||
let comp = new ICAL.Component(ICAL.parse(response.data))
|
||||
let vevent = comp.getFirstSubcomponent('vevent')
|
||||
if (!vevent) {
|
||||
alert('Error parsing ICS file')
|
||||
return
|
||||
}
|
||||
let event = new ICAL.Event(vevent)
|
||||
|
||||
let summary = {}
|
||||
summary.link = href
|
||||
summary.status = vevent.getFirstPropertyValue('status')
|
||||
summary.url = vevent.getFirstPropertyValue('url')
|
||||
summary.summary = event.summary
|
||||
summary.description = event.description
|
||||
summary.location = event.location
|
||||
summary.start = dayjs(event.startDate).format('ddd, D MMM YYYY, h:mm a')
|
||||
summary.end = dayjs(event.endDate).format('ddd, D MMM YYYY, h:mm a')
|
||||
summary.isRecurring = event.isRecurring()
|
||||
summary.organizer = event.organizer ? event.organizer.replace(/^mailto:/, '') : false
|
||||
summary.attendees = []
|
||||
event.attendees.forEach((a) => {
|
||||
if (a.jCal[1].cn) {
|
||||
summary.attendees.push(a.jCal[1].cn)
|
||||
}
|
||||
})
|
||||
|
||||
comp.getAllSubcomponents("vtimezone").forEach((vtimezone) => {
|
||||
summary.timezone = vtimezone.getFirstPropertyValue("tzid")
|
||||
})
|
||||
|
||||
this.ical = summary
|
||||
|
||||
// display modal
|
||||
this.modal('ICSView').show()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-4 border-top pt-4">
|
||||
<a v-for="part in attachments" :href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)"
|
||||
class="card attachment float-start me-3 mb-3" target="_blank" style="width: 180px">
|
||||
<img v-if="isImage(part)" :src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')"
|
||||
class="card-img-top" alt="">
|
||||
class="card attachment float-start me-3 mb-3" target="_blank" style="width: 180px"
|
||||
@click="openAttachment(part, $event)">
|
||||
<img v-if="isImage(part)"
|
||||
:src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')" class="card-img-top"
|
||||
alt="">
|
||||
<img v-else
|
||||
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg=="
|
||||
class="card-img-top" alt="">
|
||||
@@ -30,12 +87,79 @@ export default {
|
||||
<small>{{ getFileSize(part.Size) }}</small>
|
||||
</p>
|
||||
<p class="card-text mb-0 small">
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' + part.ContentType }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer small border-0 text-center text-truncate">
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' + part.ContentType }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="ICSView" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fs-5">
|
||||
<i class="bi bi-calendar-event me-2"></i>
|
||||
iCalendar summary
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" v-if="ical">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr v-if="ical.summary">
|
||||
<th>Summary</th>
|
||||
<td>{{ ical.summary }}</td>
|
||||
</tr>
|
||||
<tr v-if="ical.description">
|
||||
<th>Description</th>
|
||||
<td>{{ ical.description }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<td>
|
||||
{{ ical.start }} — {{ ical.end }}
|
||||
<span v-if="ical.isRecurring">(recurring)</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="ical.status">
|
||||
<th>Status</th>
|
||||
<td> {{ ical.status }}</td>
|
||||
</tr>
|
||||
<tr v-if="ical.location">
|
||||
<th>Location</th>
|
||||
<td>{{ ical.location }}</td>
|
||||
</tr>
|
||||
<tr v-if="ical.url">
|
||||
<th>URL</th>
|
||||
<td><a :href="ical.url" target="_blank">{{ ical.url }}</a></td>
|
||||
</tr>
|
||||
<tr v-if="ical.organizer">
|
||||
<th>Organizer</th>
|
||||
<td>{{ ical.organizer }}</td>
|
||||
</tr>
|
||||
<tr v-if="ical.attendees.length">
|
||||
<th>Attendees</th>
|
||||
<td>
|
||||
<span v-for="(a, i) in ical.attendees">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ a }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<a class="btn btn-primary" target="_blank" :href="ical.link">
|
||||
Download attachment
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import Donut from 'vue-css-donut-chart/src/components/Donut.vue'
|
||||
import { VcDonut } from 'vue-css-donut-chart'
|
||||
import axios from 'axios'
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import { Tooltip } from 'bootstrap'
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
},
|
||||
|
||||
components: {
|
||||
Donut,
|
||||
VcDonut,
|
||||
},
|
||||
|
||||
emits: ["setHtmlScore", "setBadgeStyle"],
|
||||
@@ -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">
|
||||
<vc-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>
|
||||
@@ -340,7 +327,7 @@ export default {
|
||||
calculated from {{ formatNumber(check.Total.Tests) }} tests
|
||||
</p>
|
||||
</template>
|
||||
</Donut>
|
||||
</vc-donut>
|
||||
|
||||
<div class="input-group justify-content-center mb-3">
|
||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal"
|
||||
@@ -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><script></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><script></code> node (element) has 100% failure (not supported in
|
||||
any email client), and a <code><p></code> node has 100% pass (supported).
|
||||
<code><script></code> node (element) has 100% failure (not supported
|
||||
in any email client), and a <code><p></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>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -9,6 +9,7 @@ import Tags from 'bootstrap5-tags'
|
||||
import { Tooltip } from 'bootstrap'
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import { mailbox } from '../../stores/mailbox'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -61,96 +62,145 @@ 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)
|
||||
},
|
||||
|
||||
// remove bad HTML, JavaScript, iframes etc
|
||||
sanitizedHTML() {
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.hasAttribute('href') && node.getAttribute('href').substring(0, 1) == '#') {
|
||||
return
|
||||
}
|
||||
if ('target' in node) {
|
||||
node.setAttribute('target', '_blank');
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
if (!node.hasAttribute('target') && (node.hasAttribute('xlink:href') || node.hasAttribute('href'))) {
|
||||
node.setAttribute('xlink:show', '_blank');
|
||||
}
|
||||
});
|
||||
|
||||
const clean = DOMPurify.sanitize(
|
||||
this.message.HTML,
|
||||
{
|
||||
WHOLE_DOCUMENT: true,
|
||||
SANITIZE_DOM: false,
|
||||
ADD_TAGS: [
|
||||
'link',
|
||||
'meta',
|
||||
'o:p',
|
||||
'style',
|
||||
],
|
||||
ADD_ATTR: [
|
||||
'bordercolor',
|
||||
'charset',
|
||||
'content',
|
||||
'hspace',
|
||||
'http-equiv',
|
||||
'itemprop',
|
||||
'itemscope',
|
||||
'itemtype',
|
||||
'link',
|
||||
'vertical-align',
|
||||
'vlink',
|
||||
'vspace',
|
||||
'xml:lang'
|
||||
],
|
||||
FORBID_ATTR: ['script'],
|
||||
}
|
||||
)
|
||||
|
||||
// for debugging
|
||||
// this.debugDOMPurify(DOMPurify.removed)
|
||||
|
||||
return clean
|
||||
}
|
||||
},
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl))
|
||||
|
||||
// delay 0.2s until vue has rendered the iframe content
|
||||
window.setTimeout(function () {
|
||||
// delay 0.5s until vue has rendered the iframe content
|
||||
window.setTimeout(() => {
|
||||
let p = document.getElementById('preview-html')
|
||||
if (p) {
|
||||
// make links open in new window
|
||||
let anchorEls = p.contentWindow.document.body.querySelectorAll('a')
|
||||
for (var i = 0; i < anchorEls.length; i++) {
|
||||
let anchorEl = anchorEls[i]
|
||||
let href = anchorEl.getAttribute('href')
|
||||
if (p && typeof p.contentWindow.document.body == 'object') {
|
||||
try {
|
||||
// make links open in new window
|
||||
let anchorEls = p.contentWindow.document.body.querySelectorAll('a')
|
||||
for (var i = 0; i < anchorEls.length; i++) {
|
||||
let anchorEl = anchorEls[i]
|
||||
let href = anchorEl.getAttribute('href')
|
||||
|
||||
if (href && href.match(/^http/)) {
|
||||
anchorEl.setAttribute('target', '_blank')
|
||||
if (href && href.match(/^https?:\/\//i)) {
|
||||
anchorEl.setAttribute('target', '_blank')
|
||||
}
|
||||
}
|
||||
}
|
||||
self.resizeIFrames()
|
||||
} catch (error) { }
|
||||
this.resizeIFrames()
|
||||
}
|
||||
}, 200)
|
||||
}, 500)
|
||||
|
||||
// html highlighting
|
||||
window.Prism = window.Prism || {}
|
||||
@@ -158,24 +208,28 @@ export default {
|
||||
Prism.highlightAll()
|
||||
},
|
||||
|
||||
resizeIframe: function (el) {
|
||||
resizeIframe(el) {
|
||||
let i = el.target
|
||||
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px'
|
||||
if (typeof i.contentWindow.document.body.scrollHeight == 'number') {
|
||||
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px'
|
||||
}
|
||||
},
|
||||
|
||||
resizeIFrames: function () {
|
||||
resizeIFrames() {
|
||||
if (this.scaleHTMLPreview != 'display') {
|
||||
return
|
||||
}
|
||||
let h = document.getElementById('preview-html')
|
||||
if (h) {
|
||||
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + 'px'
|
||||
if (typeof h.contentWindow.document.body.scrollHeight == 'number') {
|
||||
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + 'px'
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
// 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 +243,47 @@ export default {
|
||||
this.resizeIframe(el)
|
||||
},
|
||||
|
||||
sanitizeHTML: function (h) {
|
||||
// remove <base/> tag if set
|
||||
return h.replace(/<base .*>/mi, '')
|
||||
// this function is unused but kept here to use for debugging
|
||||
debugDOMPurify(removed) {
|
||||
if (!removed.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const ignoreNodes = ['target', 'base', 'script', 'v:shapes']
|
||||
|
||||
let d = removed.filter((r) => {
|
||||
if (typeof r.attribute != 'undefined' &&
|
||||
(ignoreNodes.includes(r.attribute.nodeName) || r.attribute.nodeName.startsWith('xmlns:'))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// inline comments
|
||||
if (typeof r.element != 'undefined' && (r.element.nodeType == 8 || r.element.tagName == 'SCRIPT')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if (d.length) {
|
||||
console.log(d)
|
||||
}
|
||||
},
|
||||
|
||||
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)
|
||||
@@ -238,7 +312,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="message" id="message-view" class="px-2 px-md-0 mh-100" style="overflow-y: scroll;">
|
||||
<div v-if="message" id="message-view" class="px-2 px-md-0 mh-100">
|
||||
<div class="row w-100">
|
||||
<div class="col-md">
|
||||
<table class="messageHeaders">
|
||||
@@ -298,7 +372,7 @@ export default {
|
||||
<tr v-if="message.Bcc && message.Bcc.length" class="small">
|
||||
<th>Bcc</th>
|
||||
<td class="privacy">
|
||||
<span v-for="( t, i ) in message.Bcc ">
|
||||
<span v-for="(t, i) in message.Bcc">
|
||||
<template v-if="i > 0">,</template>
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
<<a :href="searchURI(t.Address)" class="text-body">
|
||||
@@ -335,11 +409,13 @@ export default {
|
||||
<small class="text-body-secondary" v-else>[ no subject ]</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="d-md-none small">
|
||||
<tr class="small">
|
||||
<th class="small">Date</th>
|
||||
<td>{{ messageDate(message.Date) }}</td>
|
||||
<td>
|
||||
{{ messageDate(message.Date) }}
|
||||
<small class="ms-2">({{ getFileSize(message.Size) }})</small>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr class="small">
|
||||
<th>Tags</th>
|
||||
<td>
|
||||
@@ -516,9 +592,8 @@ export default {
|
||||
<div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel"
|
||||
aria-labelledby="nav-html-tab" tabindex="0">
|
||||
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
|
||||
<iframe target-blank="" class="tab-pane d-block" id="preview-html"
|
||||
:srcdoc="sanitizeHTML(message.HTML)" v-on:load="resizeIframe" frameborder="0"
|
||||
style="width: 100%; height: 100%; background: #fff;">
|
||||
<iframe target-blank="" class="tab-pane d-block" id="preview-html" :srcdoc="sanitizedHTML"
|
||||
v-on:load="resizeIframe" frameborder="0" style="width: 100%; height: 100%; background: #fff;">
|
||||
</iframe>
|
||||
</div>
|
||||
<Attachments v-if="allAttachments(message).length" :message="message"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import Donut from 'vue-css-donut-chart/src/components/Donut.vue'
|
||||
import { VcDonut } from 'vue-css-donut-chart'
|
||||
import axios from 'axios'
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
|
||||
@@ -9,7 +9,7 @@ export default {
|
||||
},
|
||||
|
||||
components: {
|
||||
Donut,
|
||||
VcDonut,
|
||||
},
|
||||
|
||||
emits: ["setSpamScore", "setBadgeStyle"],
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -158,7 +156,7 @@ export default {
|
||||
<template v-else-if="check">
|
||||
<div class="row w-100 mt-5">
|
||||
<div class="col-xl-5 mb-2">
|
||||
<Donut :sections="graphSections" background="var(--bs-body-bg)" :size="230" unit="px" :thickness="20"
|
||||
<vc-donut :sections="graphSections" background="var(--bs-body-bg)" :size="230" unit="px" :thickness="20"
|
||||
:total="100" :start-angle="270" :auto-adjust-text-size="true" foreground="#198754">
|
||||
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">
|
||||
{{ check.Score }} / 5
|
||||
@@ -167,7 +165,7 @@ export default {
|
||||
<span v-if="check.IsSpam" class="text-white badge rounded-pill bg-danger p-2">Spam</span>
|
||||
<span v-else class="badge rounded-pill p-2" :class="badgeStyle()">Not spam</span>
|
||||
</div>
|
||||
</Donut>
|
||||
</vc-donut>
|
||||
</div>
|
||||
<div class="col-xl-7">
|
||||
<div class="row w-100 py-2 border-bottom">
|
||||
@@ -206,10 +204,6 @@ export default {
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
Spam Analysis is currently in beta. Constructive feedback is welcome via
|
||||
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
|
||||
</p>
|
||||
<div class="accordion" id="SpamAnalysisAboutAccordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
@@ -218,13 +212,14 @@ export default {
|
||||
What is Spam Analysis?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col1" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<div id="col1" class="accordion-collapse collapse"
|
||||
data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
Mailpit integrates with SpamAssassin to provide you with some insight into the
|
||||
"spamminess" of your messages. It sends your complete message (including any
|
||||
attachments) to a running SpamAssassin server and then displays the results returned
|
||||
by SpamAssassin.
|
||||
attachments) to a running SpamAssassin server and then displays the results
|
||||
returned by SpamAssassin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,16 +231,17 @@ export default {
|
||||
How does the point system work?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col2" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<div id="col2" class="accordion-collapse collapse"
|
||||
data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
The default spam threshold is <code>5</code>, meaning any score lower than 5 is
|
||||
considered ham (not spam), and any score of 5 or above is spam.
|
||||
</p>
|
||||
<p>
|
||||
SpamAssassin will also return the tests which are triggered by the message. These
|
||||
tests can differ depending on the configuration of your SpamAssassin server. The
|
||||
total of this score makes up the the "spamminess" of the message.
|
||||
SpamAssassin will also return the tests which are triggered by the message.
|
||||
These tests can differ depending on the configuration of your SpamAssassin
|
||||
server. The total of this score makes up the the "spamminess" of the message.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -257,7 +253,8 @@ export default {
|
||||
But I don't agree with the results...
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col3" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<div id="col3" class="accordion-collapse collapse"
|
||||
data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
Mailpit does not manipulate the results nor determine the "spamminess" of
|
||||
@@ -265,8 +262,9 @@ export default {
|
||||
dependent on how SpamAssassin is set up and optionally trained.
|
||||
</p>
|
||||
<p>
|
||||
This tool is simply provided as an aid to assist you. If you are running your own
|
||||
instance of SpamAssassin, then you look into your SpamAssassin configuration.
|
||||
This tool is simply provided as an aid to assist you. If you are running your
|
||||
own instance of SpamAssassin, then you look into your SpamAssassin
|
||||
configuration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -278,14 +276,15 @@ export default {
|
||||
Where can I find more information about the triggered rules?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col4" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<div id="col4" class="accordion-collapse collapse"
|
||||
data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
Unfortunately the current <a href="https://spamassassin.apache.org/"
|
||||
target="_blank">SpamAssassin website</a> no longer contains any relative
|
||||
documentation
|
||||
about these, most likely because the rules come from different locations and change
|
||||
often. You will need to search the internet for these yourself.
|
||||
documentation about these, most likely because the rules come from different
|
||||
locations and change often. You will need to search the internet for these
|
||||
yourself.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,23 @@ 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, hideLoader) {
|
||||
if (!hideLoader) {
|
||||
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 (!hideLoader && this.loading > 0) {
|
||||
this.loading--
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -127,16 +143,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 +163,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 +183,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 +216,7 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
allAttachments: function (message) {
|
||||
allAttachments(message) {
|
||||
let a = []
|
||||
for (let i in message.Attachments) {
|
||||
a.push(message.Attachments[i])
|
||||
@@ -222,7 +235,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\//)) {
|
||||
@@ -243,6 +256,9 @@ export default {
|
||||
if (['zip', 'tar', 'rar', 'bz2', 'gz', 'xz'].includes(ext)) {
|
||||
return 'bi-file-zip-fill'
|
||||
}
|
||||
if (['ics'].includes(ext)) {
|
||||
return 'bi-calendar-event'
|
||||
}
|
||||
if (a.ContentType.match(/^audio\//)) {
|
||||
return 'bi-file-music-fill'
|
||||
}
|
||||
@@ -261,7 +277,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]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -9,10 +9,14 @@ 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],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
components: {
|
||||
AboutMailpit,
|
||||
AjaxLoader,
|
||||
@@ -26,14 +30,145 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
delayedRefresh: false,
|
||||
paginationDelayed: false, // for delayed pagination URL changes
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route(to, from) {
|
||||
this.loadMailbox()
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
mailbox.searching = false
|
||||
this.apiURI = this.resolve(`/api/v1/messages`)
|
||||
this.loadMessages()
|
||||
this.loadMailbox()
|
||||
|
||||
// subscribe to events
|
||||
this.eventBus.on("new", this.handleWSNew)
|
||||
this.eventBus.on("update", this.handleWSUpdate)
|
||||
this.eventBus.on("delete", this.handleWSDelete)
|
||||
this.eventBus.on("truncate", this.handleWSTruncate)
|
||||
},
|
||||
|
||||
unmounted() {
|
||||
// unsubscribe from events
|
||||
this.eventBus.off("new", this.handleWSNew)
|
||||
this.eventBus.off("update", this.handleWSUpdate)
|
||||
this.eventBus.off("delete", this.handleWSDelete)
|
||||
this.eventBus.off("truncate", this.handleWSTruncate)
|
||||
},
|
||||
|
||||
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()
|
||||
},
|
||||
|
||||
// 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)
|
||||
},
|
||||
|
||||
// handler for websocket new messages
|
||||
handleWSNew(data) {
|
||||
if (pagination.start < 1) {
|
||||
// push results directly into first page
|
||||
mailbox.messages.unshift(data)
|
||||
if (mailbox.messages.length > pagination.limit) {
|
||||
mailbox.messages.pop()
|
||||
}
|
||||
} else {
|
||||
// update pagination offset
|
||||
pagination.start++
|
||||
// prevent "Too many calls to Location or History APIs within a short time frame"
|
||||
this.delayedPaginationUpdate()
|
||||
}
|
||||
},
|
||||
|
||||
// handler for websocket message updates
|
||||
handleWSUpdate(data) {
|
||||
for (let x = 0; x < this.mailbox.messages.length; x++) {
|
||||
if (this.mailbox.messages[x].ID == data.ID) {
|
||||
// update message
|
||||
this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data }
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// handler for websocket message deletion
|
||||
handleWSDelete(data) {
|
||||
let removed = 0;
|
||||
for (let x = 0; x < this.mailbox.messages.length; x++) {
|
||||
if (this.mailbox.messages[x].ID == data.ID) {
|
||||
// remove message from the list
|
||||
this.mailbox.messages.splice(x, 1)
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (!removed || this.delayedRefresh) {
|
||||
// nothing changed on this screen, or a refresh is queued,
|
||||
// don't refresh
|
||||
return
|
||||
}
|
||||
|
||||
// delayedRefresh prevents unnecessary reloads when multiple messages are deleted
|
||||
this.delayedRefresh = true
|
||||
|
||||
window.setTimeout(() => {
|
||||
this.delayedRefresh = false
|
||||
this.loadMessages()
|
||||
}, 500)
|
||||
},
|
||||
|
||||
// handler for websocket message truncation
|
||||
handleWSTruncate() {
|
||||
// all messages gone, reload
|
||||
this.loadMessages()
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -55,7 +190,7 @@ export default {
|
||||
<i class="bi bi-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
<Pagination @loadMessages="loadMessages" :total="mailbox.total" />
|
||||
<Pagination :total="mailbox.total" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -66,18 +201,24 @@ export default {
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas"
|
||||
aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<NavMailbox @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
<AboutMailpit />
|
||||
<div class="offcanvas-body pb-0">
|
||||
<div class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1 overflow-y-auto">
|
||||
|
||||
<NavMailbox @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
</div>
|
||||
<AboutMailpit />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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;">
|
||||
<NavMailbox @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
<div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column">
|
||||
<div class="flex-grow-1 overflow-y-auto">
|
||||
<NavMailbox @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
</div>
|
||||
<AboutMailpit />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,10 +7,14 @@ import Release from '../components/message/Release.vue'
|
||||
import Screenshot from '../components/message/Screenshot.vue'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
components: {
|
||||
AboutMailpit,
|
||||
AjaxLoader,
|
||||
@@ -24,35 +28,120 @@ export default {
|
||||
mailbox,
|
||||
pagination,
|
||||
message: false,
|
||||
prevLink: false,
|
||||
nextLink: false,
|
||||
errorMessage: false,
|
||||
apiSideNavURI: false,
|
||||
apiSideNavParams: URLSearchParams,
|
||||
apiIsMore: true,
|
||||
messagesList: [],
|
||||
liveLoaded: 0, // the number new messages prepended tp messageList
|
||||
scrollLoading: false,
|
||||
canLoadMore: true,
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route(to, from) {
|
||||
this.loadMessage()
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
const relativeTime = require('dayjs/plugin/relativeTime')
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
this.initLoadMoreAPIParams()
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadMessage()
|
||||
|
||||
this.messagesList = JSON.parse(JSON.stringify(this.mailbox.messages))
|
||||
if (!this.messagesList.length) {
|
||||
this.loadMore()
|
||||
}
|
||||
|
||||
this.refreshUI()
|
||||
|
||||
// subscribe to events
|
||||
this.eventBus.on("new", this.handleWSNew)
|
||||
this.eventBus.on("update", this.handleWSUpdate)
|
||||
this.eventBus.on("delete", this.handleWSDelete)
|
||||
this.eventBus.on("truncate", this.handleWSTruncate)
|
||||
},
|
||||
|
||||
unmounted() {
|
||||
// unsubscribe from events
|
||||
this.eventBus.off("new", this.handleWSNew)
|
||||
this.eventBus.off("update", this.handleWSUpdate)
|
||||
this.eventBus.off("delete", this.handleWSDelete)
|
||||
this.eventBus.off("truncate", this.handleWSTruncate)
|
||||
},
|
||||
|
||||
computed: {
|
||||
// get current message read status
|
||||
isRead() {
|
||||
const l = this.messagesList.length
|
||||
if (!this.message || !l) {
|
||||
return true
|
||||
}
|
||||
|
||||
let id = false
|
||||
for (x = 0; x < l; x++) {
|
||||
if (this.messagesList[x].ID == this.message.ID) {
|
||||
return this.messagesList[x].Read
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
// get the previous message ID
|
||||
previousID() {
|
||||
const l = this.messagesList.length
|
||||
if (!this.message || !l) {
|
||||
return false
|
||||
}
|
||||
|
||||
let id = false
|
||||
for (x = 0; x < l; x++) {
|
||||
if (this.messagesList[x].ID == this.message.ID) {
|
||||
return id
|
||||
}
|
||||
id = this.messagesList[x].ID
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
// get the next message ID
|
||||
nextID() {
|
||||
const l = this.messagesList.length
|
||||
if (!this.message || !l) {
|
||||
return false
|
||||
}
|
||||
|
||||
let id = false
|
||||
for (x = l - 1; x > 0; x--) {
|
||||
if (this.messagesList[x].ID == this.message.ID) {
|
||||
return id
|
||||
}
|
||||
id = this.messagesList[x].ID
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
},
|
||||
|
||||
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)) {
|
||||
mailbox.unread--
|
||||
}
|
||||
// update read status in case websockets is not working
|
||||
this.handleWSUpdate({ 'ID': d.ID, Read: true })
|
||||
|
||||
// replace inline images embedded as inline attachments
|
||||
if (d.HTML && d.Inline) {
|
||||
@@ -61,14 +150,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,125 +170,280 @@ 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.$nextTick(() => {
|
||||
this.scrollSidebarToCurrent()
|
||||
})
|
||||
},
|
||||
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) {
|
||||
for (let m in mailbox.messages) {
|
||||
if (mailbox.messages[m].ID == id) {
|
||||
if (!mailbox.messages[m].Read) {
|
||||
mailbox.messages[m].Read = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
// UI refresh ticker to adjust relative times
|
||||
refreshUI() {
|
||||
window.setTimeout(() => {
|
||||
this.$forceUpdate()
|
||||
this.refreshUI()
|
||||
}, 30000)
|
||||
},
|
||||
|
||||
// handler for websocket new messages
|
||||
handleWSNew(data) {
|
||||
// do not add when searching or >= 100 new messages have been received
|
||||
if (this.mailbox.searching || this.liveLoaded >= 100) {
|
||||
return
|
||||
}
|
||||
|
||||
this.liveLoaded++
|
||||
this.messagesList.unshift(data)
|
||||
},
|
||||
|
||||
// handler for websocket message updates
|
||||
handleWSUpdate(data) {
|
||||
for (let x = 0; x < this.messagesList.length; x++) {
|
||||
if (this.messagesList[x].ID == data.ID) {
|
||||
// update message
|
||||
this.messagesList[x] = { ...this.messagesList[x], ...data }
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
detectPrevNext: function () {
|
||||
// generate the prev/next links based on current message list
|
||||
this.prevLink = false
|
||||
this.nextLink = false
|
||||
let found = false
|
||||
// handler for websocket message deletion
|
||||
handleWSDelete(data) {
|
||||
for (let x = 0; x < this.messagesList.length; x++) {
|
||||
if (this.messagesList[x].ID == data.ID) {
|
||||
// remove message from the list
|
||||
this.messagesList.splice(x, 1)
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
for (let m in mailbox.messages) {
|
||||
if (mailbox.messages[m].ID == this.message.ID) {
|
||||
found = true
|
||||
} else if (found && !this.nextLink) {
|
||||
this.nextLink = mailbox.messages[m].ID
|
||||
break
|
||||
// handler for websocket message truncation
|
||||
handleWSTruncate() {
|
||||
// all messages gone, go to inbox
|
||||
this.$router.push('/')
|
||||
},
|
||||
|
||||
// return whether the sidebar is visible
|
||||
sidebarVisible() {
|
||||
return this.$refs.MessageList.offsetParent != null
|
||||
},
|
||||
|
||||
// scroll sidenav to current message if found
|
||||
scrollSidebarToCurrent() {
|
||||
const cont = document.getElementById('MessageList')
|
||||
if (!cont) {
|
||||
return
|
||||
}
|
||||
const c = cont.querySelector('.router-link-active')
|
||||
if (c) {
|
||||
const outer = cont.getBoundingClientRect()
|
||||
const li = c.getBoundingClientRect()
|
||||
if (outer.top > li.top || outer.bottom < li.bottom) {
|
||||
c.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
scrollHandler(e) {
|
||||
if (!this.canLoadMore || this.scrollLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
const { scrollTop, offsetHeight, scrollHeight } = e.target
|
||||
if ((scrollTop + offsetHeight + 150) >= scrollHeight) {
|
||||
this.loadMore()
|
||||
}
|
||||
},
|
||||
|
||||
loadMore() {
|
||||
if (this.messagesList.length) {
|
||||
// get last created timestamp
|
||||
const oldest = this.messagesList[this.messagesList.length - 1].Created
|
||||
// if set append `before=<ts>`
|
||||
this.apiSideNavParams.set('before', oldest)
|
||||
}
|
||||
|
||||
this.scrollLoading = true
|
||||
|
||||
this.get(this.apiSideNavURI, this.apiSideNavParams, (response) => {
|
||||
if (response.data.messages.length) {
|
||||
this.messagesList.push(...response.data.messages)
|
||||
} else {
|
||||
this.prevLink = mailbox.messages[m].ID
|
||||
this.canLoadMore = false
|
||||
}
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.scrollLoading = false
|
||||
})
|
||||
}, null, true)
|
||||
},
|
||||
|
||||
downloadMessageBody: function (str, ext) {
|
||||
let dl = document.createElement('a')
|
||||
initLoadMoreAPIParams() {
|
||||
let apiURI = this.resolve(`/api/v1/messages`)
|
||||
let p = {}
|
||||
|
||||
if (mailbox.searching) {
|
||||
apiURI = this.resolve(`/api/v1/search`)
|
||||
p.query = mailbox.searching
|
||||
}
|
||||
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
}
|
||||
|
||||
this.apiSideNavURI = apiURI
|
||||
|
||||
this.apiSideNavParams = new URLSearchParams(p)
|
||||
},
|
||||
|
||||
getRelativeCreated(message) {
|
||||
const d = new Date(message.Created)
|
||||
return dayjs(d).fromNow()
|
||||
},
|
||||
|
||||
getPrimaryEmailTo(message) {
|
||||
for (let i in message.To) {
|
||||
return message.To[i].Address
|
||||
}
|
||||
|
||||
return '[ Undisclosed recipients ]'
|
||||
},
|
||||
|
||||
isActive(id) {
|
||||
return this.message.ID == id
|
||||
},
|
||||
|
||||
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()
|
||||
},
|
||||
|
||||
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) {
|
||||
// toggle current message read status
|
||||
toggleRead() {
|
||||
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 read = !this.isRead
|
||||
|
||||
const ids = [this.message.ID]
|
||||
const uri = this.resolve('/api/v1/messages')
|
||||
this.put(uri, { 'Read': read, 'IDs': ids }, () => {
|
||||
if (!this.sidebarVisible()) {
|
||||
return this.goBack()
|
||||
}
|
||||
|
||||
// manually update read status in case websockets is not working
|
||||
this.handleWSUpdate({ 'ID': this.message.ID, Read: read })
|
||||
})
|
||||
},
|
||||
|
||||
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')
|
||||
// calculate next ID before deletion to prevent WS race
|
||||
const goToID = this.nextID ? this.nextID : this.previousID
|
||||
|
||||
this.delete(uri, { 'IDs': ids }, () => {
|
||||
if (!this.sidebarVisible()) {
|
||||
return this.goBack()
|
||||
}
|
||||
if (goToID) {
|
||||
return this.$router.push('/view/' + goToID)
|
||||
}
|
||||
|
||||
return this.goBack()
|
||||
})
|
||||
},
|
||||
|
||||
goBack: function () {
|
||||
// return to mailbox or search based on origin
|
||||
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()
|
||||
}
|
||||
this.$router.push('/search?' + new URLSearchParams(p).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()
|
||||
}
|
||||
this.$router.push('/?' + new URLSearchParams(p).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)
|
||||
reloadWindow() {
|
||||
location.reload()
|
||||
},
|
||||
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -207,24 +451,27 @@ export default {
|
||||
|
||||
<template>
|
||||
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white">
|
||||
<div class="d-none d-md-block col-xl-2 col-md-3 col-auto pe-0">
|
||||
<div class="d-none d-xl-block col-xl-3 col-auto pe-0">
|
||||
<RouterLink to="/" class="navbar-brand text-white me-0" @click="pagination.start = 0">
|
||||
<img :src="resolve('/mailpit.svg')" alt="Mailpit">
|
||||
<span class="ms-2 d-none d-sm-inline">Mailpit</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="col col-md-4k col-lg-5 col-xl-6" v-if="!errorMessage">
|
||||
<button @click="goBack()" class="btn btn-outline-light me-3 me-sm-4 d-md-none" title="Return to messages">
|
||||
<div class="col col-xl-5" v-if="!errorMessage">
|
||||
<button @click="goBack()" class="btn btn-outline-light me-3 d-xl-none" title="Return to messages">
|
||||
<i class="bi bi-arrow-return-left"></i>
|
||||
<span class="ms-2 d-none d-lg-inline">Back</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Mark unread" v-on:click="markUnread">
|
||||
<i class="bi bi-eye-slash"></i> <span class="d-none d-md-inline">Mark unread</span>
|
||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Mark unread" v-on:click="toggleRead()">
|
||||
<i class="bi bi-eye-slash me-md-2" :class="isRead ? 'bi-eye-slash' : 'bi-eye'"></i>
|
||||
<span class="d-none d-md-inline">Mark <template v-if="isRead">un</template>read</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">
|
||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Delete message" v-on:click="deleteMessage()">
|
||||
<i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -285,24 +532,32 @@ export default {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<RouterLink :to="'/view/' + prevLink" class="btn btn-outline-light ms-1 ms-sm-2 me-1"
|
||||
:class="prevLink ? '' : 'disabled'" title="View previous message">
|
||||
<RouterLink :to="'/view/' + previousID" class="btn btn-outline-light ms-1 ms-sm-2 me-1"
|
||||
:class="previousID ? '' : 'disabled'" title="View previous message">
|
||||
<i class="bi bi-caret-left-fill"></i>
|
||||
</RouterLink>
|
||||
<RouterLink :to="'/view/' + nextLink" class="btn btn-outline-light" :class="nextLink ? '' : 'disabled'">
|
||||
<RouterLink :to="'/view/' + nextID" class="btn btn-outline-light" :class="nextID ? '' : 'disabled'">
|
||||
<i class="bi bi-caret-right-fill" title="View next message"></i>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="d-none d-xl-flex col-xl-3 h-100 flex-column">
|
||||
<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label">
|
||||
<div class="text-truncate fw-normal">
|
||||
{{ mailbox.uiConfig.Label }}
|
||||
</div>
|
||||
</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>
|
||||
<span class="ms-1">
|
||||
Return to
|
||||
<template v-if="mailbox.searching">search</template>
|
||||
<template v-else>inbox</template>
|
||||
</span>
|
||||
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
|
||||
v-if="mailbox.unread && !errorMessage">
|
||||
{{ formatNumber(mailbox.unread) }}
|
||||
@@ -310,24 +565,49 @@ export default {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4" v-if="!errorMessage">
|
||||
<div class="card-body text-body-secondary small">
|
||||
<p class="card-text">
|
||||
<b>Message date:</b><br>
|
||||
<small>{{ messageDate(message.Date) }}</small>
|
||||
</p>
|
||||
<p class="card-text">
|
||||
<b>Size:</b> {{ getFileSize(message.Size) }}
|
||||
</p>
|
||||
<p class="card-text" v-if="allAttachments(message).length">
|
||||
<b>Attachments:</b> {{ allAttachments(message).length }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-grow-1 overflow-y-auto px-1 me-n1" id="MessageList" ref="MessageList"
|
||||
@scroll="scrollHandler">
|
||||
<button v-if="liveLoaded >= 100" class="w-100 alert alert-warning small" @click="reloadWindow()">
|
||||
Reload to see newer messages
|
||||
</button>
|
||||
<template v-if="messagesList && messagesList.length">
|
||||
<div class="list-group">
|
||||
<RouterLink v-for="message in messagesList" :to="'/view/' + message.ID" :key="message.ID"
|
||||
:id="message.ID"
|
||||
class="row gx-1 message d-flex small list-group-item list-group-item-action"
|
||||
:class="message.Read ? 'read' : '', isActive(message.ID) ? 'active' : ''">
|
||||
<div class="col-12 overflow-x-hidden">
|
||||
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
|
||||
</div>
|
||||
<div class="col overflow-x-hidden">
|
||||
<div class="text-truncate privacy small">
|
||||
To: {{ getPrimaryEmailTo(message) }}
|
||||
<span v-if="message.To && message.To.length > 1">
|
||||
[+{{ message.To.length - 1 }}]
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto small">
|
||||
<i class="bi bi-paperclip h6" v-if="message.Attachments"></i>
|
||||
{{ getRelativeCreated(message) }}
|
||||
</div>
|
||||
<div v-if="message.Tags.length" class="col-12">
|
||||
<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 }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<AboutMailpit />
|
||||
</div>
|
||||
|
||||
<div class="col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0">
|
||||
<div class="col-xl-9 mh-100 ps-0 ps-md-2 pe-0">
|
||||
<div class="mh-100" style="overflow-y: auto;" id="message-page">
|
||||
<template v-if="errorMessage">
|
||||
<h3 class="text-center my-3">
|
||||
@@ -341,6 +621,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>
|
||||
|
||||
@@ -14,6 +14,9 @@ import { pagination } from '../stores/pagination'
|
||||
export default {
|
||||
mixins: [CommonMixins, MessagesMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
components: {
|
||||
AboutMailpit,
|
||||
AjaxLoader,
|
||||
@@ -28,23 +31,36 @@ export default {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
delayedRefresh: false,
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route(to, from) {
|
||||
this.doSearch(true)
|
||||
this.doSearch()
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
mailbox.searching = this.getSearch()
|
||||
this.doSearch(false)
|
||||
this.doSearch()
|
||||
|
||||
// subscribe to events
|
||||
this.eventBus.on("update", this.handleWSUpdate)
|
||||
this.eventBus.on("delete", this.handleWSDelete)
|
||||
this.eventBus.on("truncate", this.handleWSTruncate)
|
||||
},
|
||||
|
||||
unmounted() {
|
||||
// unsubscribe from events
|
||||
this.eventBus.off("update", this.handleWSUpdate)
|
||||
this.eventBus.off("delete", this.handleWSDelete)
|
||||
this.eventBus.off("truncate", this.handleWSTruncate)
|
||||
},
|
||||
|
||||
methods: {
|
||||
doSearch: function (resetPagination) {
|
||||
let s = this.getSearch()
|
||||
doSearch() {
|
||||
const s = this.getSearch()
|
||||
|
||||
if (!s) {
|
||||
mailbox.searching = false
|
||||
@@ -54,16 +70,55 @@ 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)
|
||||
}
|
||||
this.loadMessages()
|
||||
}
|
||||
},
|
||||
|
||||
// handler for websocket message updates
|
||||
handleWSUpdate(data) {
|
||||
for (let x = 0; x < this.mailbox.messages.length; x++) {
|
||||
if (this.mailbox.messages[x].ID == data.ID) {
|
||||
// update message
|
||||
this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data }
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// handler for websocket message deletion
|
||||
handleWSDelete(data) {
|
||||
let removed = 0;
|
||||
for (let x = 0; x < this.mailbox.messages.length; x++) {
|
||||
if (this.mailbox.messages[x].ID == data.ID) {
|
||||
// remove message from the list
|
||||
this.mailbox.messages.splice(x, 1)
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (!removed || this.delayedRefresh) {
|
||||
// nothing changed on this screen, or a refresh is queued, don't refresh
|
||||
return
|
||||
}
|
||||
|
||||
// delayedRefresh prevents unnecessary reloads when multiple messages are deleted
|
||||
this.delayedRefresh = true
|
||||
|
||||
window.setTimeout(() => {
|
||||
this.delayedRefresh = false
|
||||
this.loadMessages()
|
||||
}, 500)
|
||||
},
|
||||
|
||||
// handler for websocket message truncation
|
||||
handleWSTruncate() {
|
||||
// all messages deleted, go back to inbox
|
||||
this.$router.push('/')
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -86,7 +141,7 @@ export default {
|
||||
<i class="bi bi-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
<Pagination @loadMessages="loadMessages" :total="mailbox.count" />
|
||||
<Pagination :total="mailbox.count" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,18 +152,23 @@ export default {
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas"
|
||||
aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<NavSearch @loadMessages="loadMessages" />
|
||||
<NavTags @loadMessages="loadMessages" />
|
||||
<AboutMailpit />
|
||||
<div class="offcanvas-body pb-0">
|
||||
<div class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1 overflow-y-auto">
|
||||
<NavSearch @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
</div>
|
||||
<AboutMailpit />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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;">
|
||||
<NavSearch @loadMessages="loadMessages" />
|
||||
<NavTags @loadMessages="loadMessages" />
|
||||
<div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column">
|
||||
<div class="flex-grow-1 overflow-y-auto">
|
||||
<NavSearch @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
</div>
|
||||
<AboutMailpit />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
<body>
|
||||
<rapi-doc id="thedoc" spec-url="swagger.json" theme="light" layout="column" render-style="read" load-fonts="false"
|
||||
allow-authentication="false"
|
||||
regular-font="system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', 'Liberation Sans', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'"
|
||||
mono-font="Courier New, Courier, System, fixed-width" font-size="large" allow-spec-url-load="false"
|
||||
allow-spec-file-load="false" allow-server-selection="false" allow-search="false" allow-advanced-search="false"
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
"tags": [
|
||||
"Other"
|
||||
],
|
||||
"summary": "HTML check (beta)",
|
||||
"summary": "HTML check",
|
||||
"operationId": "HTMLCheck",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -172,7 +172,7 @@
|
||||
"tags": [
|
||||
"Other"
|
||||
],
|
||||
"summary": "Link check (beta)",
|
||||
"summary": "Link check",
|
||||
"operationId": "LinkCheck",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -368,7 +368,7 @@
|
||||
},
|
||||
"/api/v1/message/{ID}/sa-check": {
|
||||
"get": {
|
||||
"description": "Returns the SpamAssassin (if enabled) summary of the message.\n\nNOTE: This feature is currently in beta and is documented for reference only.\nPlease do not integrate with it (yet) as there may be changes.",
|
||||
"description": "Returns the SpamAssassin summary (if enabled) of the message.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -379,7 +379,7 @@
|
||||
"tags": [
|
||||
"Other"
|
||||
],
|
||||
"summary": "SpamAssassin check (beta)",
|
||||
"summary": "SpamAssassin check",
|
||||
"operationId": "SpamAssassinCheck",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -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",
|
||||
|
||||
@@ -3,6 +3,7 @@ package websockets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
@@ -83,5 +84,26 @@ func Broadcast(t string, msg interface{}) {
|
||||
return
|
||||
}
|
||||
|
||||
// add a very small delay to prevent broadcasts from being interpreted
|
||||
// as a multi-line messages (eg: storage.DeleteMessages() which can send a very quick series)
|
||||
time.Sleep(time.Millisecond)
|
||||
|
||||
go func() { MessageHub.Broadcast <- b }()
|
||||
}
|
||||
|
||||
// BroadCastClientError is a wrapper to broadcast client errors to the web UI
|
||||
func BroadCastClientError(severity, errorType, ip, message string) {
|
||||
msg := struct {
|
||||
Level string
|
||||
Type string
|
||||
IP string
|
||||
Message string
|
||||
}{
|
||||
severity,
|
||||
errorType,
|
||||
ip,
|
||||
message,
|
||||
}
|
||||
|
||||
Broadcast("error", msg)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user