Compare commits

...

56 Commits

Author SHA1 Message Date
Ralph Slooten
3431f18a3f Merge branch 'release/v1.6.20' 2023-06-15 17:34:15 +12:00
Ralph Slooten
2fa5138b49 Release v1.6.20 2023-06-15 17:34:15 +12:00
Ralph Slooten
652fec0f64 Merge branch 'feature/text-clickable-links' into develop 2023-06-15 17:24:42 +12:00
Ralph Slooten
f168e11b05 Libs: Update node modules 2023-06-15 17:24:32 +12:00
Ralph Slooten
35e81e0336 Feature: Convert links into clickable hyperlinks in plain text message content
@see 125
2023-06-15 17:15:46 +12:00
Ralph Slooten
7beed988e5 Merge tag 'v1.6.19' into develop
Release v1.6.19
2023-06-15 09:52:12 +12:00
Ralph Slooten
4eea79f0c8 Merge branch 'release/v1.6.19' 2023-06-15 09:52:10 +12:00
Ralph Slooten
39767e979c Release v1.6.19 2023-06-15 09:52:10 +12:00
Ralph Slooten
4e2f02ee0a Fix: Only display sendmail help when sendmail subcommand is invoked
This was overriding all Mailpit's help commands
2023-06-15 09:50:11 +12:00
Ralph Slooten
5a04534314 Add :key to message in message list 2023-06-15 09:27:26 +12:00
Ralph Slooten
6725a809d5 Increase auto-build package upload retries from 3 to 5 2023-06-14 22:33:36 +12:00
Ralph Slooten
64a067cff9 Merge tag 'v1.6.18' into develop
Release v1.6.18
2023-06-14 22:20:48 +12:00
Ralph Slooten
58dbccc0a7 Merge branch 'release/v1.6.18' 2023-06-14 22:20:40 +12:00
Ralph Slooten
3ef320d277 Release v1.6.18 2023-06-14 22:20:40 +12:00
Ralph Slooten
18e95b699e Merge branch 'feature/labels' into develop 2023-06-14 22:18:59 +12:00
Ralph Slooten
fc89655b7f UI: Add option to enable tag colors based on tag name hash
An experimental option to add tag colors (see #127). This will generate a random color for each unique tag
2023-06-14 22:18:51 +12:00
Ralph Slooten
ff9a6ff491 API: Sort tags before saving
Tags should be alphabetically sorted before saving. Whilst this was the behavior with automated tagging, it did not apply to manually set tags via the API.
2023-06-13 16:57:42 +12:00
Ralph Slooten
adce75ab8f UI: Display message tags below subject in message overview 2023-06-13 15:53:18 +12:00
Ralph Slooten
12903cae60 Merge tag 'v1.6.17' into develop
Release v1.6.17
2023-06-07 07:36:23 +12:00
Ralph Slooten
7f55511c82 Merge branch 'release/v1.6.17' 2023-06-07 07:36:21 +12:00
Ralph Slooten
309036fb6d Release v1.6.17 2023-06-07 07:36:20 +12:00
Ralph Slooten
48387c3a13 Rename hidden long flags 2023-06-07 07:35:34 +12:00
Ronaldo Richieri
a2ab350aff Fix: Add single dash arguments support to sendmail command (#123)
mailpit sendmail -t was not working because of the single dash argument
parsing. This commit fixes it.

Co-authored-by: Ronaldo Richieri <ronaldo@bestpractical.com>
2023-06-07 07:33:47 +12:00
Ralph Slooten
c150f1ba50 Merge tag 'v1.6.16' into develop
Release v1.6.16
2023-06-03 17:19:32 +12:00
Ralph Slooten
48bec0c8f6 Merge branch 'release/v1.6.16' 2023-06-03 17:19:29 +12:00
Ralph Slooten
fef2628c3f Release v1.6.16 2023-06-03 17:19:29 +12:00
Ralph Slooten
e5888ede8b Bugfix: Fix sendmail/startup panic
Fixes #122
2023-06-03 17:18:52 +12:00
Ralph Slooten
374a760b88 Merge tag 'v1.6.15' into develop
Release v1.6.15
2023-06-03 11:06:03 +12:00
Ralph Slooten
0fdfa13a38 Merge branch 'release/v1.6.15' 2023-06-03 11:06:01 +12:00
Ralph Slooten
b41df78c4f Release v1.6.15 2023-06-03 11:06:00 +12:00
Ralph Slooten
870e523c97 Merge branch 'feature/sendmail-bs' into develop 2023-06-03 11:03:17 +12:00
Ralph Slooten
0b391b5c37 Fix error return value 2023-06-03 11:02:53 +12:00
Ralph Slooten
c01f473e79 Feature: Add sendmail -bs functionality
Symfony's mailer uses `sendmail -bs` by default. This adds the required internal telnet functionality to connect directly to the SMTP server.
2023-06-03 10:57:13 +12:00
Ralph Slooten
3c27fd715b Update .gitignore 2023-06-03 10:34:22 +12:00
Ralph Slooten
714596a13a Fix plural 2023-06-02 19:11:10 +12:00
Ralph Slooten
9ae02daf1a Merge tag 'v1.6.14' into develop
Release v1.6.14
2023-06-02 17:22:13 +12:00
Ralph Slooten
b6750600cb Merge branch 'release/v1.6.14' 2023-06-02 17:22:12 +12:00
Ralph Slooten
78e871e9b3 Release v1.6.14 2023-06-02 17:22:12 +12:00
Ralph Slooten
8ff2a5cf6a Merge branch 'feature/tags' into develop 2023-06-02 17:18:46 +12:00
Ralph Slooten
4a88d1fc24 Feature: Add ability to delete or mark search results read
@see #119
2023-06-02 17:17:54 +12:00
Ralph Slooten
d4268b8ae1 Feature: Set tags via X-Tags message header
@see #119
2023-06-02 14:47:36 +12:00
Ralph Slooten
1b47716f5f Libs: Update node modules 2023-06-02 08:28:24 +12:00
Ralph Slooten
42e6d71415 Update API docs 2023-05-31 08:20:05 +12:00
Ralph Slooten
cd5789dda2 Merge tag 'v1.6.13' into develop
Release v1.6.13
2023-05-30 20:38:47 +12:00
Ralph Slooten
cd2a9d433a Merge branch 'release/v1.6.13' 2023-05-30 20:38:45 +12:00
Ralph Slooten
fe0dfe41e7 Release v1.6.13 2023-05-30 20:38:44 +12:00
Ralph Slooten
bee3174c78 Merge branch 'feature/login-auth' into develop 2023-05-30 20:37:23 +12:00
Ralph Slooten
a3187d5499 Merge tag 'v1.6.12' into develop
Release v1.6.12
2023-05-30 16:58:03 +12:00
Ralph Slooten
dc7f047b9a Merge branch 'release/v1.6.12' 2023-05-30 16:58:00 +12:00
Ralph Slooten
f3bb522143 Release v1.6.12 2023-05-30 16:58:00 +12:00
Ralph Slooten
3a41d56cc6 Merge branch 'feature/message-summary-ids' into develop 2023-05-30 16:54:41 +12:00
Ralph Slooten
db5d8f672a Swagger: Update swagger field descriptions, add MessageID 2023-05-30 16:52:39 +12:00
Ralph Slooten
3d96b2cad0 Add Message-ID to MessageSummary 2023-05-30 16:51:34 +12:00
Lars Liedtke
34c1748f4b Feature: Add Message-Id to MessageSummary (#116) 2023-05-30 16:02:25 +12:00
Ralph Slooten
52120abefd Feature: Add SMTP LOGIN authentication method for message relay
See #118
2023-05-30 15:54:26 +12:00
Ralph Slooten
086142e977 Merge tag 'v1.6.11' into develop
Release v1.6.11
2023-05-26 23:02:50 +12:00
30 changed files with 679 additions and 227 deletions

View File

@@ -44,4 +44,5 @@ jobs:
extra_files: LICENSE README.md
md5sum: false
overwrite: true
retry: 5
ldflags: -w -X "github.com/axllent/mailpit/config.Version=${{ github.ref_name }}"

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
/node_modules/
/send
/sendmail/sendmail
/server/ui/dist
/Makefile
/mailpit*

View File

@@ -2,6 +2,74 @@
Notable changes to Mailpit will be documented in this file.
## [v1.6.20]
### Feature
- Convert links into clickable hyperlinks in plain text message content
### Libs
- Update node modules
## [v1.6.19]
### Fix
- Only display sendmail help when sendmail subcommand is invoked
## [v1.6.18]
### API
- Sort tags before saving
### UI
- Add option to enable tag colors based on tag name hash
- Display message tags below subject in message overview
## [v1.6.17]
### Fix
- Add single dash arguments support to sendmail command ([#123](https://github.com/axllent/mailpit/issues/123))
## [v1.6.16]
### Bugfix
- Fix sendmail/startup panic
## [v1.6.15]
### Feature
- Add `sendmail -bs` functionality
## [v1.6.14]
### Feature
- Add ability to delete or mark search results read
- Set tags via X-Tags message header
### Libs
- Update node modules
## [v1.6.13]
### Feature
- Add SMTP LOGIN authentication method for message relay
## [v1.6.12]
### Feature
- Add Message-Id to MessageSummary ([#116](https://github.com/axllent/mailpit/issues/116))
### Swagger
- Update swagger field descriptions, add MessageID
## [v1.6.11]
### Libs

View File

@@ -1,23 +1,18 @@
package cmd
import (
"os"
sendmail "github.com/axllent/mailpit/sendmail/cmd"
"github.com/spf13/cobra"
)
var (
smtpAddr = "localhost:1025"
fromAddr string
)
// sendmailCmd represents the sendmail command
var sendmailCmd = &cobra.Command{
Use: "sendmail [flags] [recipients]",
Short: "A sendmail command replacement for Mailpit",
Long: `A sendmail command replacement for Mailpit.
You can optionally create a symlink called 'sendmail' to the Mailpit binary.`,
Run: func(_ *cobra.Command, _ []string) {
sendmail.Run()
},
}
@@ -25,13 +20,17 @@ You can optionally create a symlink called 'sendmail' to the Mailpit binary.`,
func init() {
rootCmd.AddCommand(sendmailCmd)
// these are simply repeated for cli consistency
sendmailCmd.Flags().StringVarP(&fromAddr, "from", "f", fromAddr, "SMTP sender")
sendmailCmd.Flags().StringVarP(&smtpAddr, "smtp-addr", "S", smtpAddr, "SMTP server address")
sendmailCmd.Flags().BoolVarP(&sendmail.Verbose, "verbose", "v", false, "Verbose mode (sends debug output to stderr)")
sendmailCmd.Flags().BoolP("long-b", "b", false, "Ignored. This flag exists for sendmail compatibility.")
sendmailCmd.Flags().BoolP("long-i", "i", false, "Ignored. This flag exists for sendmail compatibility.")
sendmailCmd.Flags().BoolP("long-o", "o", false, "Ignored. This flag exists for sendmail compatibility.")
sendmailCmd.Flags().BoolP("long-s", "s", false, "Ignored. This flag exists for sendmail compatibility.")
sendmailCmd.Flags().BoolP("long-t", "t", false, "Ignored. This flag exists for sendmail compatibility.")
// print out manual help screen
sendmailCmd.SetHelpTemplate(sendmail.HelpTemplate([]string{os.Args[0], "sendmail"}))
// these are simply repeated for cli consistency as cobra/viper does not allow
// multi-letter single-dash variables (-bs)
sendmailCmd.Flags().StringVarP(&sendmail.FromAddr, "from", "f", sendmail.FromAddr, "SMTP sender")
sendmailCmd.Flags().StringVarP(&sendmail.SMTPAddr, "smtp-addr", "S", sendmail.SMTPAddr, "SMTP server address")
sendmailCmd.Flags().BoolVarP(&sendmail.UseB, "long-b", "b", false, "Handle SMTP commands on standard input (use as -bs)")
sendmailCmd.Flags().BoolVarP(&sendmail.UseS, "long-s", "s", false, "Handle SMTP commands on standard input (use as -bs)")
sendmailCmd.Flags().BoolP("verbose", "v", false, "Verbose mode (sends debug output to stderr)")
sendmailCmd.Flags().BoolP("long-i", "i", false, "Ignored")
sendmailCmd.Flags().BoolP("long-o", "o", false, "Ignored")
sendmailCmd.Flags().BoolP("long-t", "t", false, "Ignored")
}

View File

@@ -11,6 +11,7 @@ import (
"strings"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/utils/tools"
"github.com/mattn/go-shellwords"
"github.com/tg123/go-htpasswd"
"gopkg.in/yaml.v3"
@@ -41,7 +42,7 @@ var (
// UIAuthFile for basic authentication
UIAuthFile string
// UIAuth used for euthentication
// UIAuth used for authentication
UIAuth *htpasswd.File
// Webroot to define the base path for the UI and API
@@ -71,8 +72,8 @@ var (
// SMTPCLITags is used to map the CLI args
SMTPCLITags string
// TagRegexp is the allowed tag characters
TagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_]){3,}$`)
// ValidTagRegexp represents a valid tag
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_]){3,}$`)
// SMTPTags are expressions to apply tags to new mail
SMTPTags []AutoTag
@@ -86,7 +87,7 @@ var (
// ReleaseEnabled is whether message releases are enabled, requires a valid SMTPRelayConfigFile
ReleaseEnabled = false
// SMTPRelayAllIncoming is whether to relay all incoming messages via preconfgured SMTP server.
// SMTPRelayAllIncoming is whether to relay all incoming messages via pre-configured SMTP server.
// Use with extreme caution!
SMTPRelayAllIncoming = false
@@ -115,11 +116,11 @@ type smtpRelayConfigStruct struct {
Port int `yaml:"port"`
STARTTLS bool `yaml:"starttls"`
AllowInsecure bool `yaml:"allow-insecure"`
Auth string `yaml:"auth"` // none, plain, cram-md5
Auth string `yaml:"auth"` // none, plain, login, cram-md5
Username string `yaml:"username"` // plain & cram-md5
Password string `yaml:"password"` // plain
Secret string `yaml:"secret"` // cram-md5
ReturnPath string `yaml:"return-path"` // allows overriding the boune address
ReturnPath string `yaml:"return-path"` // allow overriding the bounce address
RecipientAllowlist string `yaml:"recipient-allowlist"` // regex, if set needs to match for mails to be relayed
RecipientAllowlistRegexp *regexp.Regexp
}
@@ -219,8 +220,8 @@ func VerifyConfig() error {
for _, a := range args {
t := strings.Split(a, "=")
if len(t) > 1 {
tag := strings.TrimSpace(t[0])
if !TagRegexp.MatchString(tag) || len(tag) == 0 {
tag := tools.CleanTag(t[0])
if !ValidTagRegexp.MatchString(tag) || len(tag) == 0 {
return fmt.Errorf("Invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tag)
}
match := strings.TrimSpace(strings.ToLower(strings.Join(t[1:], "=")))
@@ -285,6 +286,11 @@ func parseRelayConfig(c string) error {
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
return fmt.Errorf("SMTP relay host username or password not set for PLAIN authentication (%s)", c)
}
} else if SMTPRelayConfig.Auth == "login" {
SMTPRelayConfig.Auth = "login"
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
return fmt.Errorf("SMTP relay host username or password not set for LOGIN authentication (%s)", c)
}
} else if strings.HasPrefix(SMTPRelayConfig.Auth, "cram") {
SMTPRelayConfig.Auth = "cram-md5"
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Secret == "" {

View File

@@ -15,6 +15,7 @@ Returns a JSON summary of the message and attachments.
```json
{
"ID": "d7a5543b-96dd-478b-9b60-2b465c9884de",
"MessageID": "12345.67890@localhost",
"Read": true,
"From": {
"Name": "John Doe",
@@ -31,6 +32,7 @@ Returns a JSON summary of the message and attachments.
"ReplyTo": [],
"Subject": "Message subject",
"Date": "2016-09-07T16:46:00+13:00",
"Tags": ["test"],
"Text": "Plain text MIME part of the email",
"HTML": "HTML MIME part (if exists)",
"Size": 79499,

View File

@@ -31,9 +31,11 @@ List messages in the mailbox. Messages are returned in the order of latest recei
"unread": 500,
"count": 50,
"start": 0,
"tags": ["test"],
"messages": [
{
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
"MessageID": "12345.67890@localhost",
"Read": false,
"From": {
"Name": "John Doe",
@@ -54,6 +56,7 @@ List messages in the mailbox. Messages are returned in the order of latest recei
"Bcc": [],
"Subject": "Message subject",
"Created": "2022-10-03T21:35:32.228605299+13:00",
"Tags": ["test"],
"Size": 6144,
"Attachments": 0
},

View File

@@ -30,6 +30,7 @@ Matching messages are returned in the order of latest received to oldest.
"messages": [
{
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
"MessageID": "12345.67890@localhost",
"Read": false,
"From": {
"Name": "John Doe",

4
go.mod
View File

@@ -14,6 +14,7 @@ require (
github.com/leporo/sqlf v1.4.0
github.com/mattn/go-shellwords v1.0.12
github.com/mhale/smtpd v0.8.0
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e
github.com/satori/go.uuid v1.2.0
github.com/sirupsen/logrus v1.9.2
github.com/spf13/cobra v1.7.0
@@ -40,6 +41,7 @@ require (
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/reiver/go-oi v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
@@ -49,7 +51,7 @@ require (
golang.org/x/mod v0.10.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/tools v0.9.1 // indirect
golang.org/x/tools v0.9.3 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
lukechampine.com/uint128 v1.3.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect

8
go.sum
View File

@@ -93,6 +93,10 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/reiver/go-oi v1.0.0 h1:nvECWD7LF+vOs8leNGV/ww+F2iZKf3EYjYZ527turzM=
github.com/reiver/go-oi v1.0.0/go.mod h1:RrDBct90BAhoDTxB1fenZwfykqeGvhI6LsNfStJoEkI=
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e h1:quuzZLi72kkJjl+f5AQ93FMcadG19WkS7MO6TXFOSas=
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e/go.mod h1:+5vNVvEWwEIx86DB9Ke/+a5wBI464eDRo3eF0LcfpWg=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
@@ -175,8 +179,8 @@ golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3
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.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo=
golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
golang.org/x/tools v0.9.3 h1:Gn1I8+64MsuTb/HpH+LmQtNas23LhUVr3rYZ0eKuaMM=
golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
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=

95
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"bootstrap": "^5.2.0",
"bootstrap-icons": "^1.9.1",
"bootstrap5-tags": "^1.4.41",
"color-hash": "^2.0.2",
"moment": "^2.29.4",
"prismjs": "^1.29.0",
"rapidoc": "^9.3.4",
@@ -35,9 +36,9 @@
}
},
"node_modules/@babel/parser": {
"version": "7.21.9",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.9.tgz",
"integrity": "sha512-q5PNg/Bi1OpGgx5jYlvWZwAorZepEudDMCLtj967aeS7WMont7dUZI46M2XwcIQqvUlMxWfdLFu4S/qSxeUu5g==",
"version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.5.tgz",
"integrity": "sha512-DFZMC9LJUG9PLOclRC32G63UXwzqS2koQC8dkx+PLdmt1xSePYpbT/NbsrJy8Q/muXz7o/h/d4A7Fuyixm559Q==",
"bin": {
"parser": "bin/babel-parser.js"
},
@@ -46,11 +47,11 @@
}
},
"node_modules/@babel/runtime-corejs3": {
"version": "7.21.5",
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.21.5.tgz",
"integrity": "sha512-FRqFlFKNazWYykft5zvzuEl1YyTDGsIRrjV9rvxvYkUC7W/ueBng1X68Xd6uRMzAaJ0xMKn08/wem5YS1lpX8w==",
"version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.22.5.tgz",
"integrity": "sha512-TNPDN6aBFaUox2Lu+H/Y1dKKQgr4ucz/FGyCz67RVYLsBpVpUFf1dDngzg+Od8aqbrqwyztkaZjtWCZEUOT8zA==",
"dependencies": {
"core-js-pure": "^3.25.1",
"core-js-pure": "^3.30.2",
"regenerator-runtime": "^0.13.11"
},
"engines": {
@@ -420,17 +421,17 @@
"integrity": "sha512-kXOeFbfCm4fFf2A3WwVEeQj55tMZa8c8/f9AKHMobQMkzNUfUj+antR3fRPaZJawsa1aZiP/Da3ndpZrwEe4rQ=="
},
"node_modules/@lit/reactive-element": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.1.tgz",
"integrity": "sha512-va15kYZr7KZNNPZdxONGQzpUr+4sxVu7V/VG7a8mRfPPXUyhEYj5RzXCQmGrlP3tAh0L3HHm5AjBMFYRqlM9SA==",
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.2.tgz",
"integrity": "sha512-rDfl+QnCYjuIGf5xI2sVJWdYIi56CTCwWa+nidKYX6oIuBYwUbT/vX4qbUDlHiZKJ/3FRNQ/tWJui44p6/stSA==",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.0.0"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.7.tgz",
"integrity": "sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw==",
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
@@ -1029,9 +1030,9 @@
}
},
"node_modules/bootstrap": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.2.3.tgz",
"integrity": "sha512-cEKPM+fwb3cT8NzQZYEu4HilJ3anCrWqh3CHAok1p9jXqMPsPTBhU25fBckEJHJ/p+tTxTFTsFQGM+gaHpi3QQ==",
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.0.tgz",
"integrity": "sha512-UnBV3E3v4STVNQdms6jSGO2CvOkjUMdDAVR2V5N4uCMdaIkaQjbcEAMqRimDHIs4uqBYzDAKCQwCB+97tJgHQw==",
"funding": [
{
"type": "github",
@@ -1043,7 +1044,7 @@
}
],
"peerDependencies": {
"@popperjs/core": "^2.11.6"
"@popperjs/core": "^2.11.7"
}
},
"node_modules/bootstrap-icons": {
@@ -1062,9 +1063,9 @@
]
},
"node_modules/bootstrap5-tags": {
"version": "1.5.22",
"resolved": "https://registry.npmjs.org/bootstrap5-tags/-/bootstrap5-tags-1.5.22.tgz",
"integrity": "sha512-wRpPtBBql6FvXKdSKUgFBkmmkGgsZJCzZ0bC9DvfHKylnQ4gS2pHpXB2UkIEWa/c+vENbBIyUDMSAvcZ4ujyQg=="
"version": "1.5.24",
"resolved": "https://registry.npmjs.org/bootstrap5-tags/-/bootstrap5-tags-1.5.24.tgz",
"integrity": "sha512-+mH9WfNnz81CFoyui8G166YLe0ASEiSPb17DMdejCVdIOu7YGuSmYFfCAz/oqdD09vKege9yU6/YyXPOlGURGQ=="
},
"node_modules/brace-expansion": {
"version": "2.0.1",
@@ -1163,6 +1164,11 @@
"node": ">=0.10.0"
}
},
"node_modules/color-hash": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/color-hash/-/color-hash-2.0.2.tgz",
"integrity": "sha512-6exeENAqBTuIR1wIo36mR8xVVBv6l1hSLd7Qmvf6158Ld1L15/dbahR9VUOiX7GmGJBCnQyS0EY+I8x+wa7egg=="
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -1195,9 +1201,9 @@
}
},
"node_modules/core-js-pure": {
"version": "3.30.2",
"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.30.2.tgz",
"integrity": "sha512-p/npFUJXXBkCCTIlEGBdghofn00jWG6ZOtdoIXSJmAu2QBvN0IqpZXWweOytcwE6cfx8ZvVUy1vw8zxhe4Y2vg==",
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.31.0.tgz",
"integrity": "sha512-/AnE9Y4OsJZicCzIe97JP5XoPKQJfTuEG43aEVLFJGOJpyqELod+pE6LEl63DfG1Mp8wX97LDaDpy1GmLEUxlg==",
"hasInstallScript": true,
"funding": {
"type": "opencollective",
@@ -1677,9 +1683,9 @@
}
},
"node_modules/lit": {
"version": "2.7.4",
"resolved": "https://registry.npmjs.org/lit/-/lit-2.7.4.tgz",
"integrity": "sha512-cgD7xrZoYr21mbrkZIuIrj98YTMw/snJPg52deWVV4A8icLyNHI3bF70xsJeAgwTuiq5Kkd+ZR8gybSJDCPB7g==",
"version": "2.7.5",
"resolved": "https://registry.npmjs.org/lit/-/lit-2.7.5.tgz",
"integrity": "sha512-i/cH7Ye6nBDUASMnfwcictBnsTN91+aBjXoTHF2xARghXScKxpD4F4WYI+VLXg9lqbMinDfvoI7VnZXjyHgdfQ==",
"dependencies": {
"@lit/reactive-element": "^1.6.0",
"lit-element": "^3.3.0",
@@ -1965,9 +1971,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.23.tgz",
"integrity": "sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==",
"version": "8.4.24",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.24.tgz",
"integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==",
"funding": [
{
"type": "opencollective",
@@ -2056,9 +2062,9 @@
}
},
"node_modules/punycode": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
"integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw=="
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="
},
"node_modules/qs": {
"version": "6.11.2",
@@ -2074,15 +2080,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/querystring": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
"integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==",
"deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.",
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/ramda": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.29.0.tgz",
@@ -2203,9 +2200,9 @@
"optional": true
},
"node_modules/sass": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.62.1.tgz",
"integrity": "sha512-NHpxIzN29MXvWiuswfc1W3I0N8SXBd8UR26WntmDlRYf0bSADnwnOjsyMZ3lMezSlArD33Vs3YFhp7dWvL770A==",
"version": "1.63.4",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.63.4.tgz",
"integrity": "sha512-Sx/+weUmK+oiIlI+9sdD0wZHsqpbgQg8wSwSnGBjwb5GwqFhYNwwnI+UWZtLjKvKyFlKkatRK235qQ3mokyPoQ==",
"dev": true,
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
@@ -2520,12 +2517,12 @@
"integrity": "sha512-tdOvLfRzHolwYcHS6HIX860MkK9LQ4+oLuNwFYL7bpgTEO64PZrcQxkisgwJYCfF8sKiWLwwu1c83DvMkbefIQ=="
},
"node_modules/url": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz",
"integrity": "sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ==",
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/url/-/url-0.11.1.tgz",
"integrity": "sha512-rWS3H04/+mzzJkv0eZ7vEDGiQbgquI1fGfOad6zKvgYQi1SzMmhl7c/DdRGxhaWrVH6z0qWITo8rpnxK/RfEhA==",
"dependencies": {
"punycode": "1.3.2",
"querystring": "0.2.0"
"punycode": "^1.4.1",
"qs": "^6.11.0"
}
},
"node_modules/util-deprecate": {

View File

@@ -12,6 +12,7 @@
"bootstrap": "^5.2.0",
"bootstrap-icons": "^1.9.1",
"bootstrap5-tags": "^1.4.41",
"color-hash": "^2.0.2",
"moment": "^2.29.4",
"prismjs": "^1.29.0",
"rapidoc": "^9.3.4",

View File

@@ -3,8 +3,16 @@ package cmd
/**
* Bare bones sendmail drop-in replacement borrowed from MailHog
*
* It uses a bit of a hack for flag parsing in order to be compatible
* with the cobra sendmail subcommand, as sendmail uses `-bc` which
* is not POSIX compatible.
*
* The -bs command-line switch causes sendmail to run a single SMTP session in the
* foreground over its standard input and output, and then exit. The SMTP session
* is exactly like a network SMTP session. Usually, one or more messages are
* submitted to sendmail for delivery.
*/
import (
"bytes"
"fmt"
@@ -13,21 +21,27 @@ import (
"net/smtp"
"os"
"os/user"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
"github.com/reiver/go-telnet"
flag "github.com/spf13/pflag"
)
var (
// Verbose flag
Verbose bool
// SMTPAddr address
SMTPAddr = "localhost:1025"
// FromAddr email address
FromAddr string
fromAddr string
// UseB - used to set from `-bs`
UseB bool
// UseS - used to set from `-bs`
UseS bool
)
// Run the Mailpit sendmail replacement.
func Run() {
func init() {
host, err := os.Hostname()
if err != nil {
host = "localhost"
@@ -39,47 +53,68 @@ func Run() {
username = user.Username
}
if fromAddr == "" {
fromAddr = username + "@" + host
if FromAddr == "" {
FromAddr = username + "@" + host
}
}
smtpAddr := "localhost:1025"
var recip []string
// Run the Mailpit sendmail replacement.
func Run() {
var recipients []string
// defaults from envars if provided
if len(os.Getenv("MP_SENDMAIL_SMTP_ADDR")) > 0 {
smtpAddr = os.Getenv("MP_SENDMAIL_SMTP_ADDR")
SMTPAddr = os.Getenv("MP_SENDMAIL_SMTP_ADDR")
}
if len(os.Getenv("MP_SENDMAIL_FROM")) > 0 {
fromAddr = os.Getenv("MP_SENDMAIL_FROM")
FromAddr = os.Getenv("MP_SENDMAIL_FROM")
}
// override defaults from cli flags
flag.StringVarP(&fromAddr, "from", "f", fromAddr, "SMTP sender address")
flag.StringVarP(&smtpAddr, "smtp-addr", "S", smtpAddr, "SMTP server address")
flag.BoolVarP(&Verbose, "verbose", "v", false, "Verbose mode (sends debug output to stderr)")
flag.BoolP("long-b", "b", false, "Ignored. This flag exists for sendmail compatibility.")
flag.BoolP("long-i", "i", false, "Ignored. This flag exists for sendmail compatibility.")
flag.BoolP("long-o", "o", false, "Ignored. This flag exists for sendmail compatibility.")
flag.BoolP("long-s", "s", false, "Ignored. This flag exists for sendmail compatibility.")
flag.BoolP("long-t", "t", false, "Ignored. This flag exists for sendmail compatibility.")
flag.CommandLine.SortFlags = false
flag.StringVarP(&FromAddr, "from", "f", FromAddr, "SMTP sender")
flag.StringVarP(&SMTPAddr, "smtp-addr", "S", SMTPAddr, "SMTP server address")
flag.BoolVarP(&UseB, "long-b", "b", false, "Handle SMTP commands on standard input (use as -bs)")
flag.BoolVarP(&UseS, "long-s", "s", false, "Handle SMTP commands on standard input (use as -bs)")
flag.BoolP("verbose", "v", false, "Ignored")
flag.BoolP("long-i", "i", false, "Ignored")
flag.BoolP("long-o", "o", false, "Ignored")
flag.BoolP("long-t", "t", false, "Ignored")
// set the default help
flag.Usage = func() {
fmt.Printf("A sendmail command replacement for Mailpit (%s).\n\n", config.Version)
fmt.Printf("Usage:\n %s [flags] [recipients]\n", os.Args[0])
fmt.Println("\nFlags:")
flag.PrintDefaults()
fmt.Println(HelpTemplate(os.Args[0:1]))
}
var showHelp bool
// avoid 'pflag: help requested' error
flag.BoolVarP(&showHelp, "help", "h", false, "")
flag.Parse()
// allow recipient to be passed as an argument
recip = flag.Args()
// allow recipients to be passed as an argument
recipients = flag.Args()
if Verbose {
fmt.Fprintln(os.Stdout, smtpAddr, fromAddr)
if showHelp {
flag.Usage()
os.Exit(0)
}
// ensure -bs is set
if UseB && !UseS || !UseB && UseS {
fmt.Printf("error: use -bs")
os.Exit(1)
}
// handles `sendmail -bs`
if UseB && UseS {
var caller telnet.Caller = telnet.StandardCaller
// telnet directly to SMTP
if err := telnet.DialToAndCall(SMTPAddr, caller); err != nil {
fmt.Println(err)
os.Exit(1)
}
return
}
body, err := ioutil.ReadAll(os.Stdin)
@@ -96,8 +131,8 @@ func Run() {
addresses := []string{}
if len(recip) > 0 {
addresses = recip
if len(recipients) > 0 {
addresses = recipients
} else {
// get all recipients in To, Cc and Bcc
if to, err := msg.Header.AddressList("To"); err == nil {
@@ -117,9 +152,28 @@ func Run() {
}
}
err = smtp.SendMail(smtpAddr, nil, fromAddr, addresses, body)
err = smtp.SendMail(SMTPAddr, nil, FromAddr, addresses, body)
if err != nil {
fmt.Fprintln(os.Stderr, "error sending mail")
logger.Log().Fatal(err)
}
}
// HelpTemplate returns a string of the help
func HelpTemplate(args []string) string {
return fmt.Sprintf(`A sendmail command replacement for Mailpit (%s)
Usage: %s [flags] [recipients] < message
See: https://github.com/axllent/mailpit
Flags:
-S string SMTP server address (default "localhost:1025")
-f string Set the envelope sender address (default "%s")
-bs Handle SMTP commands on standard input
-t Ignored
-i Ignored
-o Ignored
-v Ignored
`, config.Version, strings.Join(args, " "), FromAddr)
}

View File

@@ -147,7 +147,7 @@ func GetMessage(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: ID
// in: path
// description: Message ID
// description: Database ID
// required: true
// type: string
//
@@ -188,7 +188,7 @@ func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: ID
// in: path
// description: Message ID
// description: Database ID
// required: true
// type: string
// + name: PartID
@@ -237,7 +237,7 @@ func GetHeaders(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: ID
// in: path
// description: Message ID
// description: Database ID
// required: true
// type: string
//
@@ -284,7 +284,7 @@ func DownloadRaw(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: ID
// in: path
// description: Message ID
// description: Database ID
// required: true
// type: string
//
@@ -330,7 +330,7 @@ func DeleteMessages(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: ids
// in: body
// description: Message IDs to delete
// description: Database IDs to delete
// required: false
// type: DeleteRequest
//
@@ -381,7 +381,7 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: ids
// in: body
// description: Message IDs to update
// description: Database IDs to update
// required: false
// type: SetReadStatusRequest
//
@@ -459,7 +459,7 @@ func SetTags(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: ids
// in: body
// description: Message IDs to update
// description: Database IDs to update
// required: true
// type: SetTagsRequest
//
@@ -502,7 +502,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
//
// # Release message
//
// Release a message via a preconfigured external SMTP server..
// Release a message via a pre-configured external SMTP server..
//
// Consumes:
// - application/json
@@ -515,7 +515,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: ID
// in: path
// description: Message ID
// description: Database ID
// required: true
// type: string
// + name: to

View File

@@ -39,12 +39,12 @@ func Thumbnail(w http.ResponseWriter, r *http.Request) {
// Parameters:
// + name: ID
// in: path
// description: message id
// description: Database ID
// required: true
// type: string
// + name: PartID
// in: path
// description: attachment part id
// description: Attachment part ID
// required: true
// type: string
//

View File

@@ -69,6 +69,10 @@ func Send(from string, to []string, msg []byte) error {
a = smtp.PlainAuth("", config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password, config.SMTPRelayConfig.Host)
}
if config.SMTPRelayConfig.Auth == "login" {
a = LoginAuth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password)
}
if config.SMTPRelayConfig.Auth == "cram-md5" {
a = smtp.CRAMMD5Auth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Secret)
}
@@ -103,3 +107,33 @@ func Send(from string, to []string, msg []byte) error {
return c.Quit()
}
// Custom implementation of LOGIN SMTP authentication
// @see https://gist.github.com/andelf/5118732
type loginAuth struct {
username, password string
}
// LoginAuth authentication
func LoginAuth(username, password string) smtp.Auth {
return &loginAuth{username, password}
}
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
return "LOGIN", []byte{}, nil
}
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
switch string(fromServer) {
case "Username:":
return []byte(a.username), nil
case "Password:":
return []byte(a.password), nil
default:
return nil, errors.New("Unknown fromServer")
}
}
return nil, nil
}

View File

@@ -90,7 +90,7 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
_, err = storage.Store(data)
if err != nil {
logger.Log().Errorf("[db] error storing message: %d", err.Error())
logger.Log().Errorf("[db] error storing message: %s", err.Error())
return err
}

View File

@@ -1,11 +1,11 @@
<script>
import commonMixins from './mixins.js';
import Message from './templates/Message.vue';
import MessageSummary from './templates/MessageSummary.vue';
import MessageRelease from './templates/MessageRelease.vue';
import MessageToast from './templates/MessageToast.vue';
import moment from 'moment';
import Tinycon from 'tinycon';
import commonMixins from './mixins.js'
import Message from './templates/Message.vue'
import MessageSummary from './templates/MessageSummary.vue'
import MessageRelease from './templates/MessageRelease.vue'
import MessageToast from './templates/MessageToast.vue'
import moment from 'moment'
import Tinycon from 'tinycon'
export default {
mixins: [commonMixins],
@@ -74,6 +74,13 @@ export default {
},
canNext: function () {
return this.total > (this.start + this.count);
},
unreadInSearch: function () {
if (!this.searching) {
return false;
}
return this.items.filter(i => !i.Read).length;
}
},
@@ -93,6 +100,27 @@ export default {
fallback: false
});
moment.updateLocale('en', {
relativeTime: {
future: "in %s",
past: "%s ago",
s: 'seconds',
ss: '%d secs',
m: "a minute",
mm: "%d mins",
h: "an hour",
hh: "%d hours",
d: "a day",
dd: "%d days",
w: "a week",
ww: "%d weeks",
M: "a month",
MM: "%d months",
y: "a year",
yy: "%d years"
}
});
this.connect();
this.getUISettings();
this.loadMessages();
@@ -304,6 +332,24 @@ export default {
});
},
// delete messages displayed in current search
deleteSearch: function () {
let ids = this.items.map(item => item.ID);
if (!ids.length) {
return false;
}
let self = this;
let uri = 'api/v1/messages';
self.delete(uri, { 'ids': ids }, function (response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
});
},
// delete all messages from mailbox
deleteAll: function () {
let self = this;
let uri = 'api/v1/messages';
@@ -313,6 +359,7 @@ export default {
});
},
// mark current message as read
markUnread: function () {
let self = this;
if (!self.message) {
@@ -326,6 +373,7 @@ export default {
});
},
// mark all messages in mailbox as read
markAllRead: function () {
let self = this;
let uri = 'api/v1/messages'
@@ -336,6 +384,24 @@ export default {
});
},
// mark messages in current search as read
markSearchRead: function () {
let ids = this.items.map(item => item.ID);
if (!ids.length) {
return false;
}
let self = this;
let uri = 'api/v1/messages';
self.put(uri, { 'read': true, 'ids': ids }, function (response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
});
},
// mark selected messages as read
markSelectedRead: function () {
let self = this;
if (!self.selected.length) {
@@ -349,6 +415,7 @@ export default {
});
},
// mark selected messages as unread
markSelectedUnread: function () {
let self = this;
if (!self.selected.length) {
@@ -362,7 +429,7 @@ export default {
});
},
// test of any selected emails are unread
// test if any selected emails are unread
selectedHasUnread: function () {
if (!this.selected.length) {
return false;
@@ -709,7 +776,8 @@ export default {
<span v-if="!total" class="ms-2">Mailpit</span>
</a>
<div v-if="total" class="ms-md-2 d-flex bg-white border rounded-start flex-fill position-relative">
<input type="text" class="form-control border-0" v-model.trim="search" placeholder="Search mailbox">
<input type="text" class="form-control border-0" aria-label="Search" v-model.trim="search"
placeholder="Search mailbox">
<span class="btn btn-link position-absolute end-0 text-muted" v-if="search"
v-on:click="resetSearch"><i class="bi bi-x-circle"></i></span>
</div>
@@ -720,13 +788,22 @@ export default {
</form>
</div>
<div class="col-12 col-lg-5 text-end mt-2 mt-lg-0" v-if="!message && total">
<button v-if="total" class="btn btn-danger float-start d-md-none me-2" data-bs-toggle="modal"
data-bs-target="#DeleteAllModal" title="Delete all messages">
<button v-if="searching && items.length" class="btn btn-danger float-start d-md-none me-2"
data-bs-toggle="modal" data-bs-target="#DeleteSearchModal" :disabled="!items" title="Delete results">
<i class="bi bi-trash-fill"></i>
</button>
<button v-else class="btn btn-danger float-start d-md-none me-2" data-bs-toggle="modal"
data-bs-target="#DeleteAllModal" :disabled="!total || searching" title="Delete all messages">
<i class="bi bi-trash-fill"></i>
</button>
<button v-if="unread" class="btn btn-light float-start d-md-none" data-bs-toggle="modal"
data-bs-target="#MarkAllReadModal" title="Mark all read">
<button v-if="searching && items.length" class="btn btn-light float-start d-md-none" data-bs-toggle="modal"
data-bs-target="#MarkSearchReadModal" :disabled="!unreadInSearch"
:title="'Mark ' + formatNumber(unreadInSearch) + ' read'">
<i class="bi bi-check2-square"></i>
</button>
<button v-else class="btn btn-light float-start d-md-none" data-bs-toggle="modal"
data-bs-target="#MarkAllReadModal" :disabled="!unread || searching">
<i class="bi bi-check2-square"></i>
</button>
@@ -777,17 +854,28 @@ export default {
</a>
<template v-if="!message && !selected.length">
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
<button v-if="searching && items.length" class="list-group-item list-group-item-action"
data-bs-toggle="modal" data-bs-target="#MarkSearchReadModal" :disabled="!unreadInSearch">
<i class="bi bi-eye-fill me-1"></i>
Mark {{ formatNumber(unreadInSearch) }} read
</button>
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#MarkAllReadModal" :disabled="!unread || searching">
<i class="bi bi-eye-fill me-1"></i>
Mark all read
</button>
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
<button v-if="searching && items.length" class="list-group-item list-group-item-action"
data-bs-toggle="modal" data-bs-target="#DeleteSearchModal" :disabled="!items">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete {{ formatNumber(items.length) }} message<span v-if="items.length > 1">s</span>
</button>
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#DeleteAllModal" :disabled="!total || searching">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
</button>
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#EnableNotificationsModal"
v-if="isConnected && notificationsSupported && !notificationsEnabled">
@@ -808,7 +896,7 @@ export default {
</button>
<button class="list-group-item list-group-item-action" v-on:click="deleteMessages">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete
Delete selected
</button>
<button class="list-group-item list-group-item-action" v-on:click="selected = []">
<i class="bi bi-x-circle me-1"></i>
@@ -818,9 +906,23 @@ export default {
</div>
<template v-if="!selected.length && tags.length && !message">
<h6 class="mt-4 text-muted"><small>Tags</small></h6>
<div class="list-group mt-2 mb-5">
<button class="list-group-item list-group-item-action" v-for="tag in tags"
<div class="mt-4 text-muted">
<button class="btn btn-sm dropdown-toggle ms-n1" data-bs-toggle="dropdown" aria-expanded="false">
Tags
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" @click="toggleTagColors()">
<template v-if="showTagColors">Hide</template>
<template v-else>Show</template>
tag colors
</button>
</li>
</ul>
</div>
<div class="list-group mt-1 mb-5">
<button class="list-group-item list-group-item-action small px-2" v-for="tag in tags"
:style="showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"
v-on:click="tagSearch($event, tag)" :class="inSearch(tag) ? 'active' : ''">
<i class="bi bi-tag-fill" v-if="inSearch(tag)"></i>
<i class="bi bi-tag" v-else></i>
@@ -831,7 +933,7 @@ export default {
<MessageSummary v-if="message" :message="message"></MessageSummary>
<div class="position-fixed bottom-0 bg-white py-2 text-muted w-100">
<div class="position-fixed bottom-0 bg-white py-2 text-muted small w-100">
<a href="#" class="text-muted" v-on:click="loadInfo">
<i class="bi bi-info-circle-fill"></i>
About
@@ -839,13 +941,13 @@ export default {
</div>
</div>
<div class="col-lg-10 col-md-9 mh-100 pe-0">
<div class="col-lg-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0">
<div class="mh-100" style="overflow-y: auto;" :class="message ? 'd-none' : ''" id="message-page">
<div class="list-group my-2" v-if="items.length">
<a v-for="message in items" :href="'#' + message.ID"
<a v-for="message in items" :href="'#' + message.ID" :key="message.ID"
v-on:click.ctrl="toggleSelected($event, message.ID)"
v-on:click.shift="selectRange($event, message.ID)"
class="row message d-flex small list-group-item list-group-item-action border-start-0 border-end-0"
class="row gx-1 message d-flex small list-group-item list-group-item-action border-start-0 border-end-0"
:class="message.Read ? 'read' : '', isSelected(message.ID) ? 'selected' : ''">
<div class="col-lg-3">
<div class="d-lg-none float-end text-muted text-nowrap small">
@@ -871,18 +973,21 @@ export default {
</span>
</div>
</div>
<div class="col-lg-6 mt-2 mt-lg-0">
<span class="badge text-bg-secondary me-1" v-for="t in message.Tags"
:title="'Filter messages tagged with ' + t" v-on:click="tagSearch($event, t)">
{{ t }}
</span>
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
<div class="col-lg-6 col-xxl-7 mt-2 mt-lg-0">
<div><b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b></div>
<div>
<span class="badge me-1" v-for="t in message.Tags"
:style="showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }"
:title="'Filter messages tagged with ' + t" v-on:click="tagSearch($event, t)">
{{ t }}
</span>
</div>
</div>
<div class="d-none d-lg-block col-1 small text-end text-muted">
<i class="bi bi-paperclip float-start h6" v-if="message.Attachments"></i>
{{ getFileSize(message.Size) }}
</div>
<div class="d-none d-lg-block col-2 small text-end text-muted">
<div class="d-none d-lg-block col-2 col-xxl-1 small text-end text-muted">
{{ getRelativeCreated(message) }}
</div>
</a>
@@ -929,6 +1034,29 @@ export default {
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="DeleteSearchModal" tabindex="-1" aria-labelledby="DeleteSearchModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="DeleteSearchModalLabel">
Delete {{ formatNumber(items.length) }} search result<span v-if="items.length > 1">s</span>?
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
This will permanently delete {{ formatNumber(items.length) }} message<span v-if="total > 1">s</span>.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal"
v-on:click="deleteSearch">Delete</button>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel" aria-hidden="true">
<div class="modal-dialog">
@@ -949,6 +1077,30 @@ export default {
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="MarkSearchReadModal" tabindex="-1" aria-labelledby="MarkSearchReadModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="MarkSearchReadModalLabel">
Mark {{ formatNumber(unreadInSearch) }} search result<span v-if="unreadInSearch > 1">s</span> as
read?
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
This will mark {{ formatNumber(unreadInSearch) }} message<span v-if="unread > 1">s</span> as read.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" data-bs-dismiss="modal"
v-on:click="markSearchRead">Confirm</button>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="EnableNotificationsModal" tabindex="-1" aria-labelledby="EnableNotificationsModalLabel"
aria-hidden="true">

View File

@@ -5,3 +5,4 @@ $font-family-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetic
$link-decoration: none;
$primary: #2c3e50;
$list-group-disabled-color: #adb5bd;
$enable-negative-margins: true;

View File

@@ -4,6 +4,7 @@
// Configuration
@import "../../../node_modules/bootstrap/scss/functions";
@import "../../../node_modules/bootstrap/scss/variables";
@import "../../../node_modules/bootstrap/scss/variables-dark";
@import "../../../node_modules/bootstrap/scss/maps";
@import "../../../node_modules/bootstrap/scss/mixins";
@import "../../../node_modules/bootstrap/scss/utilities";

View File

@@ -1,22 +1,32 @@
import axios from 'axios';
import { Modal } from 'bootstrap';
import moment from 'moment';
import axios from 'axios'
import { Modal } from 'bootstrap'
import moment from 'moment'
import ColorHash from 'color-hash'
// FakeModal is used to return a fake Bootstrap modal
// if the ID returns nothing
// if the ID returns nothing to prevent errors.
function FakeModal() { }
FakeModal.prototype.hide = function () { alert('close fake modal') }
FakeModal.prototype.show = function () { alert('open fake modal') }
FakeModal.prototype.hide = function () { }
FakeModal.prototype.show = function () { }
// Set up the color hash generator lightness and hue to ensure darker colors
const colorHash = new ColorHash({ lightness: 0.3, saturation: [0.35, 0.5, 0.65] });
/* Common mixin functions used in apps */
const commonMixins = {
data() {
return {
loading: 0
loading: 0,
tagColorCache: {},
showTagColors: true
}
},
mounted() {
this.showTagColors = localStorage.getItem('showTagsColors')
},
methods: {
getFileSize: function (bytes) {
var i = Math.floor(Math.log(bytes) / Math.log(1024));
@@ -201,6 +211,27 @@ const commonMixins = {
}
return 'bi-file-arrow-down-fill';
},
// Returns a hex color based on a string.
// Values are stored in an array for faster lookup / processing.
colorHash: function (s) {
if (this.tagColorCache[s] != undefined) {
return this.tagColorCache[s]
}
this.tagColorCache[s] = colorHash.hex(s)
return this.tagColorCache[s]
},
toggleTagColors: function () {
if (this.showTagColors) {
localStorage.removeItem('showTagsColors')
this.showTagColors = false
} else {
localStorage.setItem('showTagsColors', '1')
this.showTagColors = true
}
}
}
}

View File

@@ -163,6 +163,27 @@ export default {
self.scrollInPlace = true;
self.$emit('loadMessages');
});
},
// Convert plain text to HTML including anchor links
textToHTML: function (s) {
// escape to HTML
let html = s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
// full links with http(s)
let re = /(\b(https?|ftp):\/\/[\-\w@:%_\+.~#?,&\/\/=;]+)\b/gim
html = html.replace(re, '<a href="$&" target="_blank" rel="noopener">$&</a>')
// plain www links without https?:// prefix
let re2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim
html = html.replace(re2, '$1<a href="http://$2" target="_blank" rel="noopener">$2</a>')
return html
}
}
}
@@ -272,28 +293,28 @@ export default {
role="tab" aria-controls="nav-html" aria-selected="true" v-if="message.HTML"
v-on:click="showMobileBtns = true; resizeIframes()">HTML</button>
<button class="nav-link" id="nav-html-source-tab" data-bs-toggle="tab" data-bs-target="#nav-html-source"
type="button" role="tab" aria-controls="nav-html-source" aria-selected="false" v-if=" message.HTML "
v-on:click=" showMobileBtns = false ">
type="button" role="tab" aria-controls="nav-html-source" aria-selected="false" v-if="message.HTML"
v-on:click=" showMobileBtns = false">
HTML <span class="d-sm-none">Src</span><span class="d-none d-sm-inline">Source</span>
</button>
<button class="nav-link" id="nav-plain-text-tab" data-bs-toggle="tab" data-bs-target="#nav-plain-text"
type="button" role="tab" aria-controls="nav-plain-text" aria-selected="false"
:class=" message.HTML == '' ? 'show' : '' " v-on:click=" showMobileBtns = false ">Text</button>
:class="message.HTML == '' ? 'show' : ''" v-on:click=" showMobileBtns = false">Text</button>
<button class="nav-link" id="nav-headers-tab" data-bs-toggle="tab" data-bs-target="#nav-headers"
type="button" role="tab" aria-controls="nav-headers" aria-selected="false"
v-on:click=" showMobileBtns = false ">
v-on:click=" showMobileBtns = false">
<span class="d-sm-none">Hdrs</span><span class="d-none d-sm-inline">Headers</span>
</button>
<button class="nav-link" id="nav-raw-tab" data-bs-toggle="tab" data-bs-target="#nav-raw" type="button"
role="tab" aria-controls="nav-raw" aria-selected="false"
v-on:click=" showMobileBtns = false ">Raw</button>
v-on:click=" showMobileBtns = false">Raw</button>
<div class="d-none d-lg-block ms-auto me-2" v-if=" showMobileBtns ">
<div class="d-none d-lg-block ms-auto me-2" v-if="showMobileBtns">
<template v-for=" vals, key in responsiveSizes ">
<button class="btn" :class=" scaleHTMLPreview == key ? 'btn-outline-primary' : '' "
:disabled=" scaleHTMLPreview == key " :title=" 'Switch to ' + key + ' view' "
v-on:click=" scaleHTMLPreview = key ">
<i class="bi" :class=" 'bi-' + key "></i>
<button class="btn" :class="scaleHTMLPreview == key ? 'btn-outline-primary' : ''"
:disabled="scaleHTMLPreview == key" :title="'Switch to ' + key + ' view'"
v-on:click=" scaleHTMLPreview = key">
<i class="bi" :class="'bi-' + key"></i>
</button>
</template>
</div>
@@ -301,31 +322,31 @@ export default {
</nav>
<div class="tab-content mb-5" id="nav-tabContent">
<div v-if=" message.HTML != '' " class="tab-pane fade show" id="nav-html" role="tabpanel"
<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=" message.HTML "
v-on:load=" resizeIframe " seamless frameborder="0" style="width: 100%; height: 100%;">
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
<iframe target-blank="" class="tab-pane d-block" id="preview-html" :srcdoc="message.HTML"
v-on:load="resizeIframe" seamless frameborder="0" style="width: 100%; height: 100%;">
</iframe>
</div>
<Attachments v-if=" allAttachments(message).length " :message=" message "
:attachments=" allAttachments(message) "></Attachments>
<Attachments v-if="allAttachments(message).length" :message="message"
:attachments="allAttachments(message)"></Attachments>
</div>
<div class="tab-pane fade" id="nav-html-source" role="tabpanel" aria-labelledby="nav-html-source-tab"
tabindex="0" v-if=" message.HTML ">
tabindex="0" v-if="message.HTML">
<pre><code class="language-html">{{ message.HTML }}</code></pre>
</div>
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab" tabindex="0"
:class=" message.HTML == '' ? 'show' : '' ">
<div class="text-view">{{ message.Text }}</div>
<Attachments v-if=" allAttachments(message).length " :message=" message "
:attachments=" allAttachments(message) "></Attachments>
:class="message.HTML == '' ? 'show' : ''">
<div class="text-view" v-html="textToHTML(message.Text)"></div>
<Attachments v-if="allAttachments(message).length" :message="message"
:attachments="allAttachments(message)"></Attachments>
</div>
<div class="tab-pane fade" id="nav-headers" role="tabpanel" aria-labelledby="nav-headers-tab" tabindex="0">
<Headers v-if=" loadHeaders " :message=" message "></Headers>
<Headers v-if="loadHeaders" :message="message"></Headers>
</div>
<div class="tab-pane fade" id="nav-raw" role="tabpanel" aria-labelledby="nav-raw-tab" tabindex="0">
<iframe v-if=" srcURI " :src=" srcURI " v-on:load=" resizeIframe " seamless frameborder="0"
<iframe v-if="srcURI" :src="srcURI" v-on:load="resizeIframe" seamless frameborder="0"
style="width: 100%; height: 300px;" id="message-src"></iframe>
</div>
</div>

View File

@@ -66,7 +66,7 @@
"parameters": [
{
"type": "string",
"description": "Message ID",
"description": "Database ID",
"name": "ID",
"in": "path",
"required": true
@@ -103,7 +103,7 @@
"parameters": [
{
"type": "string",
"description": "Message ID",
"description": "Database ID",
"name": "ID",
"in": "path",
"required": true
@@ -142,7 +142,7 @@
"parameters": [
{
"type": "string",
"description": "Message ID",
"description": "Database ID",
"name": "ID",
"in": "path",
"required": true
@@ -183,14 +183,14 @@
"parameters": [
{
"type": "string",
"description": "message id",
"description": "Database ID",
"name": "ID",
"in": "path",
"required": true
},
{
"type": "string",
"description": "attachment part id",
"description": "Attachment part ID",
"name": "PartID",
"in": "path",
"required": true
@@ -224,7 +224,7 @@
"parameters": [
{
"type": "string",
"description": "Message ID",
"description": "Database ID",
"name": "ID",
"in": "path",
"required": true
@@ -242,7 +242,7 @@
},
"/api/v1/message/{ID}/release": {
"post": {
"description": "Release a message via a preconfigured external SMTP server..",
"description": "Release a message via a pre-configured external SMTP server..",
"consumes": [
"application/json"
],
@@ -261,7 +261,7 @@
"parameters": [
{
"type": "string",
"description": "Message ID",
"description": "Database ID",
"name": "ID",
"in": "path",
"required": true
@@ -347,11 +347,11 @@
"operationId": "SetReadStatus",
"parameters": [
{
"description": "Message IDs to update",
"description": "Database IDs to update",
"name": "ids",
"in": "body",
"schema": {
"description": "Message IDs to update",
"description": "Database IDs to update",
"type": "object",
"$ref": "#/definitions/SetReadStatusRequest"
}
@@ -385,11 +385,11 @@
"operationId": "Delete",
"parameters": [
{
"description": "Message IDs to delete",
"description": "Database IDs to delete",
"name": "ids",
"in": "body",
"schema": {
"description": "Message IDs to delete",
"description": "Database IDs to delete",
"type": "object",
"$ref": "#/definitions/DeleteRequest"
}
@@ -466,12 +466,12 @@
"operationId": "SetTags",
"parameters": [
{
"description": "Message IDs to update",
"description": "Database IDs to update",
"name": "ids",
"in": "body",
"required": true,
"schema": {
"description": "Message IDs to update",
"description": "Database IDs to update",
"type": "object",
"$ref": "#/definitions/SetTagsRequest"
}
@@ -751,6 +751,10 @@
"description": "Database ID",
"type": "string"
},
"MessageID": {
"description": "Message ID",
"type": "string"
},
"Read": {
"description": "Read status",
"type": "boolean"
@@ -901,6 +905,10 @@
"description": "Whether message relaying (release) is enabled",
"type": "boolean"
},
"RecipientAllowlist": {
"description": "Allowlist of accepted recipients",
"type": "string"
},
"ReturnPath": {
"description": "Enforced Return-Path (if set) for relay bounces",
"type": "string"

View File

@@ -117,10 +117,6 @@ type DBMailSummary struct {
To []*mail.Address
Cc []*mail.Address
Bcc []*mail.Address
// Subject string
// Size int
// Inline int
// Attachments int
}
// InitDB will initialise the database
@@ -211,7 +207,7 @@ func Close() {
// Store will save an email to the database tables
func Store(body []byte) (string, error) {
// Parse message body with enmime.
// Parse message body with enmime
env, err := enmime.ReadEnvelope(bytes.NewReader(body))
if err != nil {
logger.Log().Warningf("[db] %s", err.Error())
@@ -255,7 +251,16 @@ func Store(body []byte) (string, error) {
return "", err
}
tagData := findTags(&body)
// extract tags from body matches based on --tag
tagStr := findTagsInRawMessage(&body)
// extract tags from X-Tags header
headerTags := strings.TrimSpace(env.Root.Header.Get("X-Tags"))
if headerTags != "" {
tagStr += "," + headerTags
}
tagData := uniqueTagsFromString(tagStr)
tagJSON, err := json.Marshal(tagData)
if err != nil {
@@ -303,6 +308,7 @@ func Store(body []byte) (string, error) {
c.Created = created
c.ID = id
c.MessageID = messageID
c.Attachments = attachments
c.Subject = subject
c.Size = size
@@ -321,7 +327,7 @@ func List(start, limit int) ([]MessageSummary, error) {
results := []MessageSummary{}
q := sqlf.From("mailbox").
Select(`Created, ID, Subject, Metadata, Size, Attachments, Read, Tags`).
Select(`Created, ID, MessageID, Subject, Metadata, Size, Attachments, Read, Tags`).
OrderBy("Created DESC").
Limit(limit).
Offset(start)
@@ -329,6 +335,7 @@ func List(start, limit int) ([]MessageSummary, error) {
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var created int64
var id string
var messageID string
var subject string
var metadata string
var size int
@@ -337,7 +344,7 @@ func List(start, limit int) ([]MessageSummary, error) {
var read int
em := MessageSummary{}
if err := row.Scan(&created, &id, &subject, &metadata, &size, &attachments, &read, &tags); err != nil {
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags); err != nil {
logger.Log().Error(err)
return
}
@@ -354,6 +361,7 @@ func List(start, limit int) ([]MessageSummary, error) {
em.Created = time.UnixMilli(created)
em.ID = id
em.MessageID = messageID
em.Subject = subject
em.Size = size
em.Attachments = attachments
@@ -373,7 +381,7 @@ func List(start, limit int) ([]MessageSummary, error) {
}
// Search will search a mailbox for search terms.
// The search is broken up by segments (exact phrases can be quoted), and interprits specific terms such as:
// 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 string, start, limit int) ([]MessageSummary, error) {
@@ -399,6 +407,7 @@ func Search(search string, start, limit int) ([]MessageSummary, error) {
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var created int64
var id string
var messageID string
var subject string
var metadata string
var size int
@@ -408,7 +417,7 @@ func Search(search string, start, limit int) ([]MessageSummary, error) {
var ignore string
em := MessageSummary{}
if err := row.Scan(&created, &id, &subject, &metadata, &size, &attachments, &read, &tags, &ignore, &ignore, &ignore, &ignore); err != nil {
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Error(err)
return
}
@@ -425,6 +434,7 @@ func Search(search string, start, limit int) ([]MessageSummary, error) {
em.Created = time.UnixMilli(created)
em.ID = id
em.MessageID = messageID
em.Subject = subject
em.Size = size
em.Attachments = attachments
@@ -881,7 +891,7 @@ func IsUnread(id string) bool {
return unread == 1
}
// MessageIDExists blaah
// MessageIDExists checks whether a Message-ID exists in the DB
func MessageIDExists(id string) bool {
var total int

View File

@@ -14,7 +14,7 @@ func searchParser(args []string, start, limit int) *sqlf.Stmt {
}
q := sqlf.From("mailbox").
Select(`Created, ID, Subject, Metadata, Size, Attachments, Read, Tags,
Select(`Created, ID, MessageID, Subject, Metadata, Size, Attachments, Read, Tags,
IFNULL(json_extract(Metadata, '$.To'), '{}') as ToJSON,
IFNULL(json_extract(Metadata, '$.From'), '{}') as FromJSON,
IFNULL(json_extract(Metadata, '$.Cc'), '{}') as CcJSON,

View File

@@ -69,6 +69,8 @@ type Attachment struct {
type MessageSummary struct {
// Database ID
ID string
// Message ID
MessageID string
// Read status
Read bool
// From address

View File

@@ -3,27 +3,27 @@ package storage
import (
"context"
"encoding/json"
"regexp"
"sort"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/utils/tools"
"github.com/leporo/sqlf"
)
// SetTags will set the tags for a given message ID, used via API
// SetTags will set the tags for a given database ID, used via API
func SetTags(id string, tags []string) error {
applyTags := []string{}
reg := regexp.MustCompile(`\s+`)
for _, t := range tags {
t = strings.TrimSpace(reg.ReplaceAllString(t, " "))
if t != "" && config.TagRegexp.MatchString(t) && !inArray(t, applyTags) {
t = tools.CleanTag(t)
if t != "" && config.ValidTagRegexp.MatchString(t) && !inArray(t, applyTags) {
applyTags = append(applyTags, t)
}
}
sort.Strings(applyTags)
tagJSON, err := json.Marshal(applyTags)
if err != nil {
logger.Log().Errorf("[db] setting tags for message %s", id)
@@ -42,26 +42,25 @@ func SetTags(id string, tags []string) error {
return err
}
// Used to auto-apply tags to new messages
func findTags(message *[]byte) []string {
tags := []string{}
// Find tags set via --tags in raw message.
// Returns a comma-separated string.
func findTagsInRawMessage(message *[]byte) string {
tagStr := ""
if len(config.SMTPTags) == 0 {
return tags
return tagStr
}
str := strings.ToLower(string(*message))
for _, t := range config.SMTPTags {
if !inArray(t.Tag, tags) && strings.Contains(str, t.Match) {
tags = append(tags, t.Tag)
if strings.Contains(str, t.Match) {
tagStr += "," + t.Tag
}
}
sort.Strings(tags)
return tags
return tagStr
}
// Get message tags from the database for a given message ID.
// Get message tags from the database for a given database ID
// Used when parsing a raw email.
func getMessageTags(id string) []string {
tags := []string{}
@@ -84,3 +83,31 @@ func getMessageTags(id string) []string {
return tags
}
// UniqueTagsFromString will split a string with commas, and extract a unique slice of formatted tags
func uniqueTagsFromString(s string) []string {
tags := []string{}
if s == "" {
return tags
}
parts := strings.Split(s, ",")
for _, p := range parts {
w := tools.CleanTag(p)
if w == "" {
continue
}
if config.ValidTagRegexp.MatchString(w) {
if !inArray(w, tags) {
tags = append(tags, w)
}
} else {
logger.Log().Debugf("[db] ignoring invalid tag: %s", w)
}
}
sort.Strings(tags)
return tags
}

View File

@@ -75,7 +75,7 @@ func dbCron() {
time.Sleep(60 * time.Second)
start := time.Now()
// check if database contains deleted data and has not beein in use
// check if database contains deleted data and has not been in use
// for 5 minutes, if so VACUUM
currentTime := time.Now()
diff := currentTime.Sub(dbLastAction)
@@ -167,6 +167,7 @@ func isFile(path string) bool {
return true
}
// InArray tests if a string in within an array. It is not case sensitive.
func inArray(k string, arr []string) bool {
k = strings.ToLower(k)
for _, v := range arr {

View File

@@ -1,4 +1,4 @@
// Package tools provides various methods for variouws things
// Package tools provides various methods for various things
package tools
import (
@@ -23,7 +23,7 @@ func RemoveMessageHeaders(msg []byte, headers []string) ([]byte, error) {
reBlank := regexp.MustCompile(`^\s+`)
for _, hdr := range headers {
// case-insentitive
// case-insensitive
reHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta(hdr+":"))
// header := []byte(hdr + ":")

25
utils/tools/tags.go Normal file
View File

@@ -0,0 +1,25 @@
package tools
import (
"regexp"
"strings"
)
var (
// Invalid tag characters regex
tagsInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9\-\ \_]`)
// Regex to catch multiple spaces
multiSpaceRe = regexp.MustCompile(`(\s+)`)
)
// CleanTag returns a clean tag, removing whitespace and invalid characters
func CleanTag(s string) string {
s = strings.TrimSpace(
multiSpaceRe.ReplaceAllString(
tagsInvalidChars.ReplaceAllString(s, " "),
" ",
),
)
return s
}