Fork imapsql-ctl utility from go-imap-sql repo

1. There is only one version for maddy and imapsql-ctl utility.
This prevents confusion about compatibility.

2. Modified imapsql-ctl understands maddy config format, this allows
it to read needed values from it without the need for lengthy commmand
line arguments.

Closes #148.
This commit is contained in:
fox.cpp 2019-10-16 23:07:40 +03:00
parent d227fe269e
commit ae8fe2b14e
No known key found for this signature in database
GPG key ID: E76D97CCEDE90B6C
20 changed files with 1623 additions and 23 deletions

1
.gitignore vendored
View file

@ -22,6 +22,7 @@ _testmain.go
# Compiled binaries
cmd/maddy/maddy
cmd/imapsql-ctl/imapsql-ctl
cmd/maddy-*-helper/maddy-*-helper
# Config files

View file

@ -149,7 +149,7 @@ You need to change the following directives to your values:
With that configuration you will get the following:
- SQLite-based storage for messages
- Authentication using SQLite-based virtual users DB (use [imapsql-ctl]( https://github.com/foxcpp/go-imap-sql/tree/master/cmd/imapsql-ctl) to create user accounts)
- Authentication using SQLite-based virtual users DB (see [imapsql-ctl utility](#imapsql-ctl-utility))
- SMTP endpoint for incoming messages on 25 port.
- SMTP Submission endpoint for messages from your users, on both 587 (STARTTLS)
and 465 (TLS) ports.
@ -177,27 +177,35 @@ below.
Note that it will require users to specify full address as username when logging in.
## SQL-based database
## imapsql-ctl utility
Currently, the only supported storage and authentication DB implementation
is SQL-based go-imap-sql library.
Use the following commands to install the `imapsql-ctl` utility:
To manage virtual users, mailboxes and messages in them imapsql-ctl utility
should be used. It can be installed using the following command:
```
export GO111MODULE=on
go get github.com/foxcpp/go-imap-sql/cmd/imapsql-ctl@dev
go get github.com/foxcpp/maddy/cmd/imapsql-ctl@master
```
**Note:** Use the same version as maddy, e.g. if you installed maddy X.Y.Z,
then use the following command:
```
go get github.com/foxcpp/maddy/cmd/imapsql-ctl@vX.Y.Z
```
It can be used to create/delete virtual users as well as mailboxes
and messages in them.
As with any other `go get` command, binary will be placed in `$GOPATH/bin`
($HOME/go/bin by default).
Here is the command to use to create a new virtual user `NAME`:
Here is the command to create virtual user account:
```
imapsql-ctl --driver DRIVER --dsn DSN --fsstore FSSTORE_PATH users create NAME
imapsql-ctl users create foxcpp
```
Replace DRIVER and DSN with your values from maddy config.
For default configuration it is `--driver sqlite3 --dsn /var/lib/maddy/all.db --fsstore /var/lib/maddy/sql-local_mailboxes-fsstore`.
It assumes you use default locations for state directory and config file.
If that's not the case, provide `-config` and/or `-state` arguments that
specify used values.
### PostgreSQL instead of SQLite

17
cmd/imapsql-ctl/README.md Normal file
View file

@ -0,0 +1,17 @@
imapsql-ctl utility
-------------------
Maddy fork of utility from go-imap-sql repo, extended with functionality to
parse maddy configuration files.
#### --unsafe option
Per RFC 3501, server must send notifications to clients about any mailboxes
change. Since imapsql-ctl is a low-level tool it doesn't implements any way to
tell server to send such notifications. Most popular SQL RDBMSs don't provide
any means to detect database change and we currently have no plans on
implementing anything for that on go-imap-sql level.
Therefore, you generally should avoid writting to mailboxes if client who owns
this mailbox is connected to the server. Failure to send required notifications
may result in data damage depending on client implementation.

View file

@ -0,0 +1,35 @@
package main
import appendlimit "github.com/emersion/go-imap-appendlimit"
// Copied from go-imap-backend-tests.
// AppendLimitBackend is extension for main backend interface (backend.Backend) which
// allows to set append limit value for testing and administration purposes.
type AppendLimitBackend interface {
appendlimit.Backend
// SetMessageLimit sets new value for limit.
// nil pointer means no limit.
SetMessageLimit(val *uint32) error
}
// AppendLimitUser is extension for backend.User interface which allows to
// set append limit value for testing and administration purposes.
type AppendLimitUser interface {
appendlimit.User
// SetMessageLimit sets new value for limit.
// nil pointer means no limit.
SetMessageLimit(val *uint32) error
}
// AppendLimitMbox is extension for backend.Mailbox interface which allows to
// set append limit value for testing and administration purposes.
type AppendLimitMbox interface {
CreateMessageLimit() *uint32
// SetMessageLimit sets new value for limit.
// nil pointer means no limit.
SetMessageLimit(val *uint32) error
}

65
cmd/imapsql-ctl/config.go Normal file
View file

@ -0,0 +1,65 @@
package main
import (
"errors"
"os"
imapsql "github.com/foxcpp/go-imap-sql"
"github.com/foxcpp/maddy/config"
"github.com/foxcpp/maddy/config/parser"
"github.com/foxcpp/maddy/storage/sql"
)
func backendFromCfg(path, cfgBlock string) (*imapsql.Backend, error) {
f, err := os.Open(path)
nodes, err := parser.Read(f, path)
if err != nil {
return nil, err
}
defer f.Close()
// Global variables relevant for sql module.
globals := config.NewMap(nil, &config.Node{Children: nodes})
globals.Bool("auth_perdomain", false, false, nil)
globals.StringList("auth_domains", false, false, nil, nil)
globals.AllowUnknown()
_, err = globals.Process()
if err != nil {
return nil, err
}
for _, node := range nodes {
if node.Name != "sql" {
continue
}
if len(node.Args) == 0 && cfgBlock == "sql" {
return backendFromNode(globals.Values, node)
}
for _, arg := range node.Args {
if arg == cfgBlock {
return backendFromNode(globals.Values, node)
}
}
}
return nil, errors.New("no requested block found in configuration")
}
func backendFromNode(globals map[string]interface{}, node config.Node) (*imapsql.Backend, error) {
instName := "sql"
if len(node.Args) >= 1 {
instName = node.Args[0]
}
mod, err := sql.New("sql", instName, nil, nil)
if err != nil {
return nil, err
}
if err := mod.Init(config.NewMap(globals, &node)); err != nil {
return nil, err
}
return mod.(*sql.Storage).Back, nil
}

65
cmd/imapsql-ctl/flags.go Normal file
View file

@ -0,0 +1,65 @@
package main
import (
"errors"
"github.com/emersion/go-imap"
"github.com/urfave/cli"
)
func msgsFlags(ctx *cli.Context) error {
if err := connectToDB(ctx); err != nil {
return err
}
if !ctx.GlobalBool("unsafe") {
return errors.New("Error: Refusing to edit mailboxes without --unsafe")
}
username := ctx.Args().First()
if username == "" {
return errors.New("Error: USERNAME is required")
}
name := ctx.Args().Get(1)
if name == "" {
return errors.New("Error: MAILBOX is required")
}
seqStr := ctx.Args().Get(2)
if seqStr == "" {
return errors.New("Error: SEQ is required")
}
seq, err := imap.ParseSeqSet(seqStr)
if err != nil {
return err
}
u, err := backend.GetUser(username)
if err != nil {
return err
}
mbox, err := u.GetMailbox(name)
if err != nil {
return err
}
flags := ctx.Args()[3:]
if len(flags) == 0 {
return errors.New("Error: at least once FLAG is required")
}
var op imap.FlagsOp
switch ctx.Command.Name {
case "add-flags":
op = imap.AddFlags
case "rem-flags":
op = imap.RemoveFlags
case "set-flags":
op = imap.SetFlags
default:
panic("unknown command: " + ctx.Command.Name)
}
return mbox.UpdateMessagesFlags(ctx.IsSet("uid"), seq, op, flags)
}

428
cmd/imapsql-ctl/main.go Normal file
View file

@ -0,0 +1,428 @@
package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
imapsql "github.com/foxcpp/go-imap-sql"
"github.com/foxcpp/maddy/config"
"github.com/urfave/cli"
"golang.org/x/crypto/bcrypt"
)
var backend *imapsql.Backend
var stdinScnr *bufio.Scanner
func connectToDB(ctx *cli.Context) error {
if ctx.GlobalIsSet("unsafe") && !ctx.GlobalIsSet("quiet") {
fmt.Fprintln(os.Stderr, "WARNING: Using --unsafe with running server may lead to accidential damage to data due to desynchronization with connected clients.")
}
driver := ctx.GlobalString("driver")
if driver != "" {
// Construct artificial config file tree from command line arguments
// and pass to initialization logic.
cfg := config.Node{
Name: "sql",
Children: []config.Node{
{
Name: "driver",
Args: []string{ctx.GlobalString("driver")},
},
{
Name: "dsn",
Args: []string{ctx.GlobalString("dsn")},
},
{
Name: "fsstore",
Args: []string{ctx.GlobalString("fsstore")},
},
},
}
var err error
backend, err = backendFromNode(make(map[string]interface{}), cfg)
if err != nil {
return err
}
} else {
cfg := ctx.GlobalString("config")
cfgAbs, err := filepath.Abs(cfg)
if err != nil {
return err
}
cfgBlock := ctx.GlobalString("cfg-block")
if err := os.Chdir(ctx.GlobalString("state")); err != nil {
return err
}
backend, err = backendFromCfg(cfgAbs, cfgBlock)
if err != nil {
return err
}
}
backend.EnableSpecialUseExt()
return nil
}
func closeBackend(ctx *cli.Context) (err error) {
if backend != nil {
return backend.Close()
}
return nil
}
func main() {
stdinScnr = bufio.NewScanner(os.Stdin)
app := cli.NewApp()
app.Name = "imapsql-ctl"
app.Copyright = "(c) 2019 Max Mazurov <fox.cpp@disroot.org>\n Published under the terms of the MIT license (https://opensource.org/licenses/MIT)"
app.Usage = "SQL database management utility for maddy"
app.Version = buildInfo()
app.After = closeBackend
app.Flags = []cli.Flag{
cli.StringFlag{
Name: "config",
Usage: "Configuration file to use",
EnvVar: "MADDY_CONFIG",
Value: "/etc/maddy/maddy.conf",
},
cli.StringFlag{
Name: "state",
Usage: "State directory to use",
EnvVar: "MADDY_STATE",
Value: "/var/lib/maddy",
},
cli.StringFlag{
Name: "cfg-block",
Usage: "SQL module configuration to use",
EnvVar: "MADDY_CFGBLOCK",
Value: "local_mailboxes",
},
cli.StringFlag{
Name: "driver",
Usage: "Directly specify driver value instead of reading it from config",
EnvVar: "MADDY_SQL_DRIVER",
Value: "",
},
cli.StringFlag{
Name: "dsn",
Usage: "Directly specify dsn value instead of reading it from config",
EnvVar: "MADDY_SQL_DSN",
Value: "",
},
cli.StringFlag{
Name: "fsstore",
Usage: "Directly specify fsstore value instead of reading it from config",
EnvVar: "MADDY_SQL_FSSTORE",
Value: "",
},
cli.BoolFlag{
Name: "quiet,q",
Usage: "Don't print user-friendly messages to stderr",
},
cli.BoolFlag{
Name: "unsafe",
Usage: "Allow to perform actions that can be safely done only without running server",
},
}
app.Commands = []cli.Command{
{
Name: "mboxes",
Usage: "Mailboxes (folders) management",
Subcommands: []cli.Command{
{
Name: "list",
Usage: "Show mailboxes of user",
ArgsUsage: "USERNAME",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "subscribed,s",
Usage: "List only subscribed mailboxes",
},
},
Action: mboxesList,
},
{
Name: "create",
Usage: "Create mailbox",
ArgsUsage: "USERNAME NAME",
Action: mboxesCreate,
Flags: []cli.Flag{
cli.StringFlag{
Name: "special",
Usage: "Set SPECIAL-USE attribute on mailbox; valid values: archive, drafts, junk, sent, trash",
},
},
},
{
Name: "remove",
Usage: "Remove mailbox (requires --unsafe)",
Description: "WARNING: All contents of mailbox will be irrecoverably lost.",
ArgsUsage: "USERNAME MAILBOX",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "yes,y",
Usage: "Don't ask for confirmation",
},
},
Action: mboxesRemove,
},
{
Name: "rename",
Usage: "Rename mailbox (requires --unsafe)",
Description: "Rename may cause unexpected failures on client-side so be careful.",
ArgsUsage: "USERNAME OLDNAME NEWNAME",
Action: mboxesRename,
},
{
Name: "appendlimit",
Usage: "Query or set user's APPENDLIMIT value",
ArgsUsage: "USERNAME",
Flags: []cli.Flag{
cli.IntFlag{
Name: "value,v",
Usage: "Set APPENDLIMIT to specified value (in bytes). Pass -1 to disable limit.",
},
},
Action: mboxesAppendLimit,
},
},
},
{
Name: "msgs",
Usage: "Messages management",
Subcommands: []cli.Command{
{
Name: "add",
Usage: "Add message to mailbox (requires --unsafe)",
ArgsUsage: "USERNAME MAILBOX",
Description: "Reads message body (with headers) from stdin. Prints UID of created message on success.",
Flags: []cli.Flag{
cli.StringSliceFlag{
Name: "flag,f",
Usage: "Add flag to message. Can be specified multiple times",
},
cli.Int64Flag{
Name: "date,d",
Usage: "Set internal date value to specified UNIX timestamp",
},
},
Action: msgsAdd,
},
{
Name: "add-flags",
Usage: "Add flags to messages (requires --unsafe)",
ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...",
Description: "Add flags to all messages matched by SEQ.",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "uid,u",
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
},
Action: msgsFlags,
},
{
Name: "rem-flags",
Usage: "Remove flags from messages (requires --unsafe)",
ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...",
Description: "Remove flags from all messages matched by SEQ.",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "uid,u",
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
},
Action: msgsFlags,
},
{
Name: "set-flags",
Usage: "Set flags on messages (requires --unsafe)",
ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...",
Description: "Set flags on all messages matched by SEQ.",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "uid,u",
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
},
Action: msgsFlags,
},
{
Name: "remove",
Usage: "Remove messages from mailbox (requires --unsafe)",
ArgsUsage: "USERNAME MAILBOX SEQSET",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "uid,u",
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
cli.BoolFlag{
Name: "yes,y",
Usage: "Don't ask for confirmation",
},
},
Action: msgsRemove,
},
{
Name: "copy",
Usage: "Copy messages between mailboxes (requires --unsafe)",
Description: "Note: You can't copy between mailboxes of different users. APPENDLIMIT of target mailbox is not enforced.",
ArgsUsage: "USERNAME SRCMAILBOX SEQSET TGTMAILBOX",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "uid,u",
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
},
Action: msgsCopy,
},
{
Name: "move",
Usage: "Move messages between mailboxes (requires --unsafe)",
Description: "Note: You can't move between mailboxes of different users. APPENDLIMIT of target mailbox is not enforced.",
ArgsUsage: "USERNAME SRCMAILBOX SEQSET TGTMAILBOX",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "uid,u",
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
},
Action: msgsMove,
},
{
Name: "list",
Usage: "List messages in mailbox",
Description: "If SEQSET is specified - only show messages that match it.",
ArgsUsage: "USERNAME MAILBOX [SEQSET]",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "uid,u",
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
cli.BoolFlag{
Name: "full,f",
Usage: "Show entire envelope and all server meta-data",
},
},
Action: msgsList,
},
{
Name: "dump",
Usage: "Dump message body",
Description: "If passed SEQ matches multiple messages - they will be joined.",
ArgsUsage: "USERNAME MAILBOX SEQ",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "uid,u",
Usage: "Use UIDs for SEQ instead of sequence numbers",
},
},
Action: msgsDump,
},
},
},
{
Name: "users",
Usage: "User accounts management",
Subcommands: []cli.Command{
{
Name: "list",
Usage: "List created user accounts",
Action: usersList,
},
{
Name: "create",
Usage: "Create user account",
Description: "Reads password from stdin",
ArgsUsage: "USERNAME",
Flags: []cli.Flag{
cli.StringFlag{
Name: "password,p",
Usage: "Use `PASSWORD instead of reading password from stdin.\n\t\tWARNING: Provided only for debugging convenience. Don't leave your passwords in shell history!",
},
cli.BoolFlag{
Name: "null,n",
Usage: "Create account with null password",
},
cli.StringFlag{
Name: "hash",
Usage: "Use specified hash algorithm. Valid values: sha3-512, bcrypt",
Value: "bcrypt",
},
cli.IntFlag{
Name: "bcrypt-cost",
Usage: "Specify bcrypt cost value",
Value: bcrypt.DefaultCost,
},
},
Action: usersCreate,
},
{
Name: "remove",
Usage: "Delete user account (requires --unsafe)",
ArgsUsage: "USERNAME",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "yes,y",
Usage: "Don't ask for confirmation",
},
},
Action: usersRemove,
},
{
Name: "password",
Usage: "Change account password",
Description: "Reads password from stdin",
ArgsUsage: "USERNAME",
Flags: []cli.Flag{
cli.StringFlag{
Name: "password,p",
Usage: "Use `PASSWORD` instead of reading password from stdin.\n\t\tWARNING: Provided only for debugging convenience. Don't leave your passwords in shell history!",
},
cli.BoolFlag{
Name: "null,n",
Usage: "Set password to null",
},
cli.StringFlag{
Name: "hash",
Usage: "Use specified hash algorithm. Valid values: sha3-512, bcrypt",
Value: "sha3-512",
},
cli.IntFlag{
Name: "bcrypt-cost",
Usage: "Specify bcrypt cost value",
Value: bcrypt.DefaultCost,
},
},
Action: usersPassword,
},
{
Name: "appendlimit",
Usage: "Query or set user's APPENDLIMIT value",
ArgsUsage: "USERNAME",
Flags: []cli.Flag{
cli.IntFlag{
Name: "value,v",
Usage: "Set APPENDLIMIT to specified value (in bytes)",
},
},
Action: usersAppendLimit,
},
},
},
}
if err := app.Run(os.Args); err != nil {
fmt.Fprintln(os.Stderr, err)
}
}

210
cmd/imapsql-ctl/mboxes.go Normal file
View file

@ -0,0 +1,210 @@
package main
import (
"errors"
"fmt"
"os"
"strings"
eimap "github.com/emersion/go-imap"
imapsql "github.com/foxcpp/go-imap-sql"
"github.com/urfave/cli"
)
func mboxesList(ctx *cli.Context) error {
if err := connectToDB(ctx); err != nil {
return err
}
username := ctx.Args().First()
if username == "" {
return errors.New("Error: USERNAME is required")
}
u, err := backend.GetUser(username)
if err != nil {
return err
}
mboxes, err := u.ListMailboxes(ctx.Bool("subscribed,s"))
if err != nil {
return err
}
if len(mboxes) == 0 && !ctx.GlobalBool("quiet") {
fmt.Fprintln(os.Stderr, "No mailboxes.")
}
for _, mbox := range mboxes {
info, err := mbox.Info()
if err != nil {
return err
}
if len(info.Attributes) != 0 {
fmt.Print(info.Name, "\t", info.Attributes, "\n")
} else {
fmt.Println(info.Name)
}
}
return nil
}
func mboxesCreate(ctx *cli.Context) error {
if err := connectToDB(ctx); err != nil {
return err
}
username := ctx.Args().First()
if username == "" {
return errors.New("Error: USERNAME is required")
}
name := ctx.Args().Get(1)
if name == "" {
return errors.New("Error: NAME is required")
}
u, err := backend.GetUser(username)
if err != nil {
return err
}
if ctx.IsSet("special") {
attr := "\\" + strings.Title(ctx.String("special"))
return u.(*imapsql.User).CreateMailboxSpecial(name, attr)
}
return u.CreateMailbox(name)
}
func mboxesRemove(ctx *cli.Context) error {
if err := connectToDB(ctx); err != nil {
return err
}
if !ctx.GlobalBool("unsafe") {
return errors.New("Error: Refusing to edit mailboxes without --unsafe")
}
username := ctx.Args().First()
if username == "" {
return errors.New("Error: USERNAME is required")
}
name := ctx.Args().Get(1)
if name == "" {
return errors.New("Error: NAME is required")
}
u, err := backend.GetUser(username)
if err != nil {
return err
}
mbox, err := u.GetMailbox(name)
if err != nil {
return err
}
if !ctx.Bool("yes,y") {
status, err := mbox.Status([]eimap.StatusItem{eimap.StatusMessages})
if err != nil {
return err
}
if status.Messages != 0 {
fmt.Fprintf(os.Stderr, "Mailbox %s contains %d messages.\n", name, status.Messages)
}
if !Confirmation("Are you sure you want to delete that mailbox?", false) {
return errors.New("Cancelled")
}
}
if err := u.DeleteMailbox(name); err != nil {
return err
}
return nil
}
func mboxesRename(ctx *cli.Context) error {
if err := connectToDB(ctx); err != nil {
return err
}
if !ctx.GlobalBool("unsafe") {
return errors.New("Error: Refusing to edit mailboxes without --unsafe")
}
username := ctx.Args().First()
if username == "" {
return errors.New("Error: USERNAME is required")
}
oldName := ctx.Args().Get(1)
if oldName == "" {
return errors.New("Error: OLDNAME is required")
}
newName := ctx.Args().Get(2)
if newName == "" {
return errors.New("Error: NEWNAME is required")
}
u, err := backend.GetUser(username)
if err != nil {
return err
}
return u.RenameMailbox(oldName, newName)
}
func mboxesAppendLimit(ctx *cli.Context) error {
if err := connectToDB(ctx); err != nil {
return err
}
username := ctx.Args().First()
if username == "" {
return errors.New("Error: USERNAME is required")
}
name := ctx.Args().Get(1)
if name == "" {
return errors.New("Error: MAILBOX is required")
}
u, err := backend.GetUser(username)
if err != nil {
return err
}
mbox, err := u.GetMailbox(name)
if err != nil {
return err
}
mboxAL := mbox.(AppendLimitMbox)
if ctx.IsSet("value,v") {
val := ctx.Int("value,v")
var err error
if val == -1 {
err = mboxAL.SetMessageLimit(nil)
} else {
val32 := uint32(val)
err = mboxAL.SetMessageLimit(&val32)
}
if err != nil {
return err
}
} else {
lim := mboxAL.CreateMessageLimit()
if lim == nil {
fmt.Println("No limit")
} else {
fmt.Println(*lim)
}
}
return nil
}

344
cmd/imapsql-ctl/msgs.go Normal file
View file

@ -0,0 +1,344 @@
package main
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"time"
eimap "github.com/emersion/go-imap"
imapsql "github.com/foxcpp/go-imap-sql"
"github.com/urfave/cli"
)
func msgsAdd(ctx *cli.Context) error {
if err := connectToDB(ctx); err != nil {
return err
}
if !ctx.GlobalBool("unsafe") {
return errors.New("Error: Refusing to edit mailboxes without --unsafe")
}
username := ctx.Args().First()
if username == "" {
return errors.New("Error: USERNAME is required")
}
name := ctx.Args().Get(1)
if name == "" {
return errors.New("Error: MAILBOX is required")
}
u, err := backend.GetUser(username)
if err != nil {
return err
}
mbox, err := u.GetMailbox(name)
if err != nil {
return err
}
flags := ctx.StringSlice("flag")
if flags == nil {
flags = []string{}
}
date := time.Now()
if ctx.IsSet("date") {
date = time.Unix(ctx.Int64("date"), 0)
}
buf := bytes.Buffer{}
if _, err := io.Copy(&buf, os.Stdin); err != nil {
return err
}
if buf.Len() == 0 {
return errors.New("Error: Empty message, refusing to continue")
}
status, err := mbox.Status([]eimap.StatusItem{eimap.StatusUidNext})
if err != nil {
return err
}
if err := mbox.CreateMessage(flags, date, &buf); err != nil {
return err
}
fmt.Println(status.UidNext)
return nil
}
func msgsRemove(ctx *cli.Context) error {
if err := connectToDB(ctx); err != nil {
return err
}
if !ctx.GlobalBool("unsafe") {
return errors.New("Error: Refusing to edit mailboxes without --unsafe")
}
username := ctx.Args().First()
if username == "" {
return errors.New("Error: USERNAME is required")
}
name := ctx.Args().Get(1)
if name == "" {
return errors.New("Error: MAILBOX is required")
}
seqset := ctx.Args().Get(2)
if seqset == "" {
return errors.New("Error: SEQSET is required")
}
seq, err := eimap.ParseSeqSet(seqset)
if err != nil {
return err
}
u, err := backend.GetUser(username)
if err != nil {
return err
}
mbox, err := u.GetMailbox(name)
if err != nil {
return err
}
if !ctx.Bool("yes") {
if !Confirmation("Are you sure you want to delete these messages?", false) {
return errors.New("Cancelled")
}
}
mboxB := mbox.(*imapsql.Mailbox)
if err := mboxB.DelMessages(ctx.Bool("uid"), seq); err != nil {
return err
}
return nil
}
func msgsCopy(ctx *cli.Context) error {
if err := connectToDB(ctx); err != nil {
return err
}
if !ctx.GlobalBool("unsafe") {
return errors.New("Error: Refusing to edit mailboxes without --unsafe")
}
username := ctx.Args().First()
if username == "" {
return errors.New("Error: USERNAME is required")
}
srcName := ctx.Args().Get(1)
if srcName == "" {
return errors.New("Error: SRCMAILBOX is required")
}
seqset := ctx.Args().Get(2)
if seqset == "" {
return errors.New("Error: SEQSET is required")
}
tgtName := ctx.Args().Get(3)
if tgtName == "" {
return errors.New("Error: TGTMAILBOX is required")
}
seq, err := eimap.ParseSeqSet(seqset)
if err != nil {
return err
}
u, err := backend.GetUser(username)
if err != nil {
return err
}
srcMbox, err := u.GetMailbox(srcName)
if err != nil {
return err
}
return srcMbox.CopyMessages(ctx.Bool("uid"), seq, tgtName)
}
func msgsMove(ctx *cli.Context) error {
if err := connectToDB(ctx); err != nil {
return err
}
if !ctx.GlobalBool("unsafe") {
return errors.New("Error: Refusing to edit mailboxes without --unsafe")
}
username := ctx.Args().First()
if username == "" {
return errors.New("Error: USERNAME is required")
}
srcName := ctx.Args().Get(1)
if srcName == "" {
return errors.New("Error: SRCMAILBOX is required")
}
seqset := ctx.Args().Get(2)
if seqset == "" {
return errors.New("Error: SEQSET is required")
}
tgtName := ctx.Args().Get(3)
if tgtName == "" {
return errors.New("Error: TGTMAILBOX is required")
}
seq, err := eimap.ParseSeqSet(seqset)
if err != nil {
return err
}
u, err := backend.GetUser(username)
if err != nil {
return err
}
srcMbox, err := u.GetMailbox(srcName)
if err != nil {
return err
}
moveMbox := srcMbox.(*imapsql.Mailbox)
return moveMbox.MoveMessages(ctx.Bool("uid"), seq, tgtName)
}
func msgsList(ctx *cli.Context) error {
if err := connectToDB(ctx); err != nil {
return err
}
username := ctx.Args().First()
if username == "" {
return errors.New("Error: USERNAME is required")
}
mboxName := ctx.Args().Get(1)
if mboxName == "" {
return errors.New("Error: MAILBOX is required")
}
seqset := ctx.Args().Get(2)
if seqset == "" {
seqset = "*"
}
seq, err := eimap.ParseSeqSet(seqset)
if err != nil {
return err
}
u, err := backend.GetUser(username)
if err != nil {
return err
}
mbox, err := u.GetMailbox(mboxName)
if err != nil {
return err
}
ch := make(chan *eimap.Message, 10)
go func() {
err = mbox.ListMessages(ctx.Bool("uid"), seq, []eimap.FetchItem{eimap.FetchEnvelope, eimap.FetchInternalDate, eimap.FetchRFC822Size, eimap.FetchFlags, eimap.FetchUid}, ch)
}()
for msg := range ch {
if !ctx.Bool("full") {
fmt.Printf("UID %d: %s - %s\n %v, %v\n\n", msg.Uid, FormatAddressList(msg.Envelope.From), msg.Envelope.Subject, msg.Flags, msg.Envelope.Date)
continue
}
fmt.Println("- Server meta-data:")
fmt.Println("UID:", msg.Uid)
fmt.Println("Sequence number:", msg.SeqNum)
fmt.Println("Flags:", msg.Flags)
fmt.Println("Body size:", msg.Size)
fmt.Println("Internal date:", msg.InternalDate.Unix(), msg.InternalDate)
fmt.Println("- Envelope:")
if len(msg.Envelope.From) != 0 {
fmt.Println("From:", FormatAddressList(msg.Envelope.From))
}
if len(msg.Envelope.To) != 0 {
fmt.Println("To:", FormatAddressList(msg.Envelope.To))
}
if len(msg.Envelope.Cc) != 0 {
fmt.Println("CC:", FormatAddressList(msg.Envelope.Cc))
}
if len(msg.Envelope.Bcc) != 0 {
fmt.Println("BCC:", FormatAddressList(msg.Envelope.Bcc))
}
if msg.Envelope.InReplyTo != "" {
fmt.Println("In-Reply-To:", msg.Envelope.InReplyTo)
}
if msg.Envelope.MessageId != "" {
fmt.Println("Message-Id:", msg.Envelope.MessageId)
}
if !msg.Envelope.Date.IsZero() {
fmt.Println("Date:", msg.Envelope.Date.Unix(), msg.Envelope.Date)
}
if msg.Envelope.Subject != "" {
fmt.Println("Subject:", msg.Envelope.Subject)
}
fmt.Println()
}
return err
}
func msgsDump(ctx *cli.Context) error {
if err := connectToDB(ctx); err != nil {
return err
}
username := ctx.Args().First()
if username == "" {
return errors.New("Error: USERNAME is required")
}
mboxName := ctx.Args().Get(1)
if mboxName == "" {
return errors.New("Error: MAILBOX is required")
}
seqset := ctx.Args().Get(2)
if seqset == "" {
seqset = "*"
}
seq, err := eimap.ParseSeqSet(seqset)
if err != nil {
return err
}
u, err := backend.GetUser(username)
if err != nil {
return err
}
mbox, err := u.GetMailbox(mboxName)
if err != nil {
return err
}
ch := make(chan *eimap.Message, 10)
go func() {
err = mbox.ListMessages(ctx.Bool("uid"), seq, []eimap.FetchItem{eimap.FetchRFC822}, ch)
}()
for msg := range ch {
for _, v := range msg.Body {
if _, err := io.Copy(os.Stdout, v); err != nil {
return err
}
}
}
return err
}

3
cmd/imapsql-ctl/mysql.go Normal file
View file

@ -0,0 +1,3 @@
package main
import _ "github.com/go-sql-driver/mysql"

View file

@ -0,0 +1,3 @@
package main
import _ "github.com/lib/pq"

View file

@ -0,0 +1,5 @@
// +build cgo
package main
import _ "github.com/mattn/go-sqlite3"

View file

@ -0,0 +1,63 @@
//+build linux
package main
// Copied from github.com/foxcpp/ttyprompt
// Commit 087a574, terminal/termios.go
import (
"errors"
"os"
"syscall"
"unsafe"
)
type Termios struct {
Iflag uint32
Oflag uint32
Cflag uint32
Lflag uint32
Cc [20]byte
Ispeed uint32
Ospeed uint32
}
/*
TurnOnRawIO sets flags suitable for raw I/O (no echo, per-character input, etc)
and returns original flags.
*/
func TurnOnRawIO(tty *os.File) (orig Termios, err error) {
termios, err := TcGetAttr(tty.Fd())
if err != nil {
return Termios{}, errors.New("TurnOnRawIO: failed to get flags: " + err.Error())
}
termiosOrig := *termios
termios.Lflag &^= syscall.ECHO
termios.Lflag &^= syscall.ICANON
termios.Iflag &^= syscall.IXON
termios.Lflag &^= syscall.ISIG
termios.Iflag |= syscall.IUTF8
err = TcSetAttr(tty.Fd(), termios)
if err != nil {
return Termios{}, errors.New("TurnOnRawIO: flags to set flags: " + err.Error())
}
return termiosOrig, nil
}
func TcSetAttr(fd uintptr, termios *Termios) error {
_, _, err := syscall.Syscall(syscall.SYS_IOCTL, fd, syscall.TCSETS, uintptr(unsafe.Pointer(termios)))
if err != 0 {
return err
}
return nil
}
func TcGetAttr(fd uintptr) (*Termios, error) {
termios := &Termios{}
_, _, err := syscall.Syscall(syscall.SYS_IOCTL, fd, syscall.TCGETS, uintptr(unsafe.Pointer(termios)))
if err != 0 {
return nil, err
}
return termios, nil
}

View file

@ -0,0 +1,30 @@
//+build !linux
package main
import (
"errors"
"os"
)
type Termios struct {
Iflag uint32
Oflag uint32
Cflag uint32
Lflag uint32
Cc [20]byte
Ispeed uint32
Ospeed uint32
}
func TurnOnRawIO(tty *os.File) (orig Termios, err error) {
return Termios{}, errors.New("not implemented")
}
func TcSetAttr(fd uintptr, termios *Termios) error {
return errors.New("not implemented")
}
func TcGetAttr(fd uintptr) (*Termios, error) {
return nil, errors.New("not implemented")
}

189
cmd/imapsql-ctl/users.go Normal file
View file

@ -0,0 +1,189 @@
package main
import (
"errors"
"fmt"
"os"
"github.com/urfave/cli"
"golang.org/x/crypto/bcrypt"
)
func usersList(ctx *cli.Context) error {
if err := connectToDB(ctx); err != nil {
return err
}
list, err := backend.ListUsers()
if err != nil {
return err
}
if len(list) == 0 && !ctx.GlobalBool("quiet") {
fmt.Fprintln(os.Stderr, "No users.")
}
for _, user := range list {
fmt.Println(user)
}
return nil
}
func usersCreate(ctx *cli.Context) error {
if err := connectToDB(ctx); err != nil {
return err
}
username := ctx.Args().First()
if username == "" {
return errors.New("Error: USERNAME is required")
}
_, err := backend.GetUser(username)
if err == nil {
return errors.New("Error: User already exists")
}
if ctx.IsSet("null") {
return backend.CreateUserNoPass(username)
}
if ctx.IsSet("hash") {
backend.Opts.DefaultHashAlgo = ctx.String("hash")
}
if ctx.IsSet("bcrypt-cost") {
if ctx.Int("bcrypt-cost") > bcrypt.MaxCost {
return errors.New("Error: too big bcrypt cost")
}
if ctx.Int("bcrypt-cost") < bcrypt.MinCost {
return errors.New("Error: too small bcrypt cost")
}
backend.Opts.BcryptCost = ctx.Int("bcrypt-cost")
}
var pass string
if ctx.IsSet("password") {
pass = ctx.String("password,p")
} else {
pass, err = ReadPassword("Enter password for new user")
if err != nil {
return err
}
}
return backend.CreateUser(username, pass)
}
func usersRemove(ctx *cli.Context) error {
if err := connectToDB(ctx); err != nil {
return err
}
if !ctx.GlobalBool("unsafe") {
return errors.New("Error: Refusing to edit mailboxes without --unsafe")
}
username := ctx.Args().First()
if username == "" {
return errors.New("Error: USERNAME is required")
}
_, err := backend.GetUser(username)
if err != nil {
return errors.New("Error: User doesn't exists")
}
if !ctx.Bool("yes") {
if !Confirmation("Are you sure you want to delete this user account?", false) {
return errors.New("Cancelled")
}
}
return backend.DeleteUser(username)
}
func usersPassword(ctx *cli.Context) error {
if err := connectToDB(ctx); err != nil {
return err
}
username := ctx.Args().First()
if username == "" {
return errors.New("Error: USERNAME is required")
}
_, err := backend.GetUser(username)
if err != nil {
return errors.New("Error: User doesn't exists")
}
if ctx.IsSet("null") {
return backend.ResetPassword(username)
}
if ctx.IsSet("hash") {
backend.Opts.DefaultHashAlgo = ctx.String("hash")
}
if ctx.IsSet("bcrypt-cost") {
if ctx.Int("bcrypt-cost") > bcrypt.MaxCost {
return errors.New("Error: too big bcrypt cost")
}
if ctx.Int("bcrypt-cost") < bcrypt.MinCost {
return errors.New("Error: too small bcrypt cost")
}
backend.Opts.BcryptCost = ctx.Int("bcrypt-cost")
}
var pass string
if ctx.IsSet("password") {
pass = ctx.String("password")
} else {
pass, err = ReadPassword("Enter new password")
if err != nil {
return err
}
}
return backend.SetUserPassword(username, pass)
}
func usersAppendLimit(ctx *cli.Context) error {
if err := connectToDB(ctx); err != nil {
return err
}
username := ctx.Args().First()
if username == "" {
return errors.New("Error: USERNAME is required")
}
u, err := backend.GetUser(username)
if err != nil {
return err
}
userAL := u.(AppendLimitUser)
if ctx.IsSet("value") {
val := ctx.Int("value")
var err error
if val == -1 {
err = userAL.SetMessageLimit(nil)
} else {
val32 := uint32(val)
err = userAL.SetMessageLimit(&val32)
}
if err != nil {
return err
}
} else {
lim := userAL.CreateMessageLimit()
if lim == nil {
fmt.Println("No limit")
} else {
fmt.Println(*lim)
}
}
return nil
}

111
cmd/imapsql-ctl/utils.go Normal file
View file

@ -0,0 +1,111 @@
package main
import (
"errors"
"fmt"
"os"
"strings"
"github.com/emersion/go-imap"
eimap "github.com/emersion/go-imap"
)
func FormatAddress(addr *eimap.Address) string {
return fmt.Sprintf("%s <%s@%s>", addr.PersonalName, addr.MailboxName, addr.HostName)
}
func FormatAddressList(addrs []*imap.Address) string {
res := make([]string, 0, len(addrs))
for _, addr := range addrs {
res = append(res, FormatAddress(addr))
}
return strings.Join(res, ", ")
}
func Confirmation(prompt string, def bool) bool {
selection := "y/N"
if def {
selection = "Y/n"
}
fmt.Fprintf(os.Stderr, "%s [%s]: ", prompt, selection)
if !stdinScnr.Scan() {
fmt.Fprintln(os.Stderr, stdinScnr.Err())
return false
}
switch stdinScnr.Text() {
case "Y", "y":
return true
case "N", "n":
return false
default:
return def
}
}
func readPass(tty *os.File, output []byte) ([]byte, error) {
cursor := output[0:1]
readen := 0
for {
n, err := tty.Read(cursor)
if n != 1 {
return nil, errors.New("ReadPassword: invalid read size when not in canonical mode")
}
if err != nil {
return nil, errors.New("ReadPassword: " + err.Error())
}
if cursor[0] == '\n' {
break
}
// Esc or Ctrl+D or Ctrl+C.
if cursor[0] == '\x1b' || cursor[0] == '\x04' || cursor[0] == '\x03' {
return nil, errors.New("ReadPassword: prompt rejected")
}
if cursor[0] == '\x7F' /* DEL */ {
if readen != 0 {
readen--
cursor = output[readen : readen+1]
}
continue
}
if readen == cap(output) {
return nil, errors.New("ReadPassword: too long password")
}
readen++
cursor = output[readen : readen+1]
}
return output[0:readen], nil
}
func ReadPassword(prompt string) (string, error) {
termios, err := TurnOnRawIO(os.Stdin)
hiddenPass := true
if err != nil {
hiddenPass = false
fmt.Fprintln(os.Stderr, "Failed to disable terminal output:", err)
}
defer TcSetAttr(os.Stdin.Fd(), &termios)
fmt.Fprintf(os.Stderr, "%s: ", prompt)
if hiddenPass {
buf := make([]byte, 512)
buf, err = readPass(os.Stdin, buf)
if err != nil {
return "", err
}
fmt.Println()
return string(buf), nil
} else {
if !stdinScnr.Scan() {
return "", stdinScnr.Err()
}
return stdinScnr.Text(), nil
}
}

View file

@ -0,0 +1,25 @@
package main
import (
"fmt"
"runtime/debug"
)
const Version = "unknown (built from source tree)"
func buildInfo() string {
if info, ok := debug.ReadBuildInfo(); ok {
imapsqlVer := "unknown"
for _, dep := range info.Deps {
if dep.Path == "github.com/foxcpp/go-imap-sql" {
imapsqlVer = dep.Version
}
}
if info.Main.Version == "(devel)" {
return fmt.Sprintf("%s for maddy %s", imapsqlVer, Version)
}
return fmt.Sprintf("%s for maddy %s %s", imapsqlVer, info.Main.Version, info.Main.Sum)
}
return fmt.Sprintf("unknown for maddy %s (GOPATH build)", Version)
}

3
go.mod
View file

@ -21,7 +21,8 @@ require (
github.com/lib/pq v1.2.0
github.com/mattn/go-sqlite3 v1.11.0
github.com/stretchr/testify v1.4.0 // indirect
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7 // indirect
github.com/urfave/cli v1.20.0
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7
golang.org/x/sys v0.0.0-20190919044723-0c1ff786ef13 // indirect
google.golang.org/appengine v1.6.2 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect

View file

@ -36,8 +36,7 @@ func createTestDB(tb testing.TB, compAlgo string) *Storage {
tb.Fatal(err)
}
return &Storage{
back: db,
hostname: "test.example.org",
Back: db,
}
}

View file

@ -36,7 +36,7 @@ import (
)
type Storage struct {
back *imapsql.Backend
Back *imapsql.Backend
instName string
Log log.Logger
@ -44,7 +44,6 @@ type Storage struct {
authPerDomain bool
authDomains []string
junkMbox string
hostname string
resolver dns.Resolver
}
@ -127,7 +126,7 @@ func (store *Storage) Start(msgMeta *module.MsgMetadata, mailFrom string) (modul
store: store,
msgMeta: msgMeta,
mailFrom: mailFrom,
d: store.back.NewDelivery(),
d: store.Back.NewDelivery(),
addedRcpts: map[string]struct{}{},
}, nil
}
@ -192,7 +191,6 @@ func (store *Storage) Init(cfg *config.Map) error {
cfg.Int("sqlite3_busy_timeout", false, false, 0, &opts.BusyTimeout)
cfg.Bool("sqlite3_exclusive_lock", false, false, &opts.ExclusiveLock)
cfg.String("junk_mailbox", false, false, "Junk", &store.junkMbox)
cfg.String("hostname", true, true, "", &store.hostname)
if _, err := cfg.Process(); err != nil {
return err
@ -253,7 +251,7 @@ func (store *Storage) Init(cfg *config.Map) error {
}
}
store.back, err = imapsql.New(driver, dsnStr, extStore, opts)
store.Back, err = imapsql.New(driver, dsnStr, extStore, opts)
if err != nil {
return fmt.Errorf("sql: %s", err)
}
@ -268,11 +266,11 @@ func (store *Storage) IMAPExtensions() []string {
}
func (store *Storage) Updates() <-chan backend.Update {
return store.back.Updates()
return store.Back.Updates()
}
func (store *Storage) EnableChildrenExt() bool {
return store.back.EnableChildrenExt()
return store.Back.EnableChildrenExt()
}
func (store *Storage) CheckPlain(username, password string) bool {
@ -281,7 +279,7 @@ func (store *Storage) CheckPlain(username, password string) bool {
return false
}
return store.back.CheckPlain(accountName, password)
return store.Back.CheckPlain(accountName, password)
}
func (store *Storage) GetOrCreateUser(username string) (backend.User, error) {
@ -296,7 +294,7 @@ func (store *Storage) GetOrCreateUser(username string) (backend.User, error) {
accountName = parts[0]
}
return store.back.GetOrCreateUser(accountName)
return store.Back.GetOrCreateUser(accountName)
}
func init() {