From ae6decd876b89c74ac2428232a337d4f09404b1e Mon Sep 17 00:00:00 2001 From: "fox.cpp" Date: Sun, 20 Oct 2019 01:49:35 +0300 Subject: [PATCH] Redesign imapsql-ctl utility (now named maddyctl) Now it is not tied go-imap-sql details (with the exception of special features), allowing it to be used with other storage backends that will be added in the future. --unsafe flag is removed and now maddyctl explicitly asks for confirmation in cases where transaction may be unsafe for connected clients. --yes flag disables that. In the future, maddy can be extended with IPC interface to push updates so it this restriction can be lifted altogether. --- .gitignore | 2 +- README.md | 19 +- cmd/imapsql-ctl/README.md | 17 - cmd/imapsql-ctl/appendlimit.go | 35 - cmd/imapsql-ctl/config.go | 65 -- cmd/imapsql-ctl/flags.go | 65 -- cmd/imapsql-ctl/main.go | 428 ------------ cmd/imapsql-ctl/mboxes.go | 210 ------ cmd/imapsql-ctl/mysql.go | 3 - cmd/imapsql-ctl/postgresql.go | 3 - cmd/imapsql-ctl/users.go | 189 ------ cmd/imapsql-ctl/version.go | 25 - cmd/maddyctl/appendlimit.go | 131 ++++ .../clitools/clitools.go} | 31 +- .../clitools}/termios.go | 2 +- .../clitools}/termios_stub.go | 2 +- cmd/maddyctl/config.go | 37 + cmd/{imapsql-ctl/msgs.go => maddyctl/imap.go} | 261 +++++-- cmd/maddyctl/main.go | 635 ++++++++++++++++++ cmd/maddyctl/sql.go | 37 + cmd/{imapsql-ctl => maddyctl}/sqlite3.go | 2 +- cmd/maddyctl/users.go | 152 +++++ cmd/maddyctl/version.go | 16 + storage/sql/sql.go | 4 + 24 files changed, 1241 insertions(+), 1130 deletions(-) delete mode 100644 cmd/imapsql-ctl/README.md delete mode 100644 cmd/imapsql-ctl/appendlimit.go delete mode 100644 cmd/imapsql-ctl/config.go delete mode 100644 cmd/imapsql-ctl/flags.go delete mode 100644 cmd/imapsql-ctl/main.go delete mode 100644 cmd/imapsql-ctl/mboxes.go delete mode 100644 cmd/imapsql-ctl/mysql.go delete mode 100644 cmd/imapsql-ctl/postgresql.go delete mode 100644 cmd/imapsql-ctl/users.go delete mode 100644 cmd/imapsql-ctl/version.go create mode 100644 cmd/maddyctl/appendlimit.go rename cmd/{imapsql-ctl/utils.go => maddyctl/clitools/clitools.go} (72%) rename cmd/{imapsql-ctl => maddyctl/clitools}/termios.go (98%) rename cmd/{imapsql-ctl => maddyctl/clitools}/termios_stub.go (96%) create mode 100644 cmd/maddyctl/config.go rename cmd/{imapsql-ctl/msgs.go => maddyctl/imap.go} (50%) create mode 100644 cmd/maddyctl/main.go create mode 100644 cmd/maddyctl/sql.go rename cmd/{imapsql-ctl => maddyctl}/sqlite3.go (68%) create mode 100644 cmd/maddyctl/users.go create mode 100644 cmd/maddyctl/version.go diff --git a/.gitignore b/.gitignore index 7080732..6925788 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,7 @@ _testmain.go # Compiled binaries cmd/maddy/maddy -cmd/imapsql-ctl/imapsql-ctl +cmd/maddyctl/maddyctl cmd/maddy-*-helper/maddy-*-helper # Config files diff --git a/README.md b/README.md index fbc8b0d..ddf0b14 100644 --- a/README.md +++ b/README.md @@ -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 (see [imapsql-ctl utility](#imapsql-ctl-utility)) +- Authentication using SQLite-based virtual users DB (see [maddyctl utility](#maddyctl-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,20 +177,17 @@ below. Note that it will require users to specify full address as username when logging in. -## imapsql-ctl utility +## maddyctl utility -Currently, the only supported storage and authentication DB implementation -is SQL-based go-imap-sql library. - -To manage virtual users, mailboxes and messages in them imapsql-ctl utility +To manage virtual users, mailboxes and messages maddyctl utility should be used. It can be installed using the following command: ``` -go get github.com/foxcpp/maddy/cmd/imapsql-ctl@master +go get github.com/foxcpp/maddy/cmd/maddyctl@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 +go get github.com/foxcpp/maddy/cmd/maddyctl@vX.Y.Z ``` As with any other `go get` command, binary will be placed in `$GOPATH/bin` @@ -199,12 +196,12 @@ As with any other `go get` command, binary will be placed in `$GOPATH/bin` Here is the command to create virtual user account: ``` -imapsql-ctl users create foxcpp +maddyctl users create foxcpp ``` 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. +If that's not the case, provide `-config` and/or `-state` arguments +*before "users" subcommand* that specify used values. ### PostgreSQL instead of SQLite diff --git a/cmd/imapsql-ctl/README.md b/cmd/imapsql-ctl/README.md deleted file mode 100644 index 5f6f82e..0000000 --- a/cmd/imapsql-ctl/README.md +++ /dev/null @@ -1,17 +0,0 @@ -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. diff --git a/cmd/imapsql-ctl/appendlimit.go b/cmd/imapsql-ctl/appendlimit.go deleted file mode 100644 index 93cab7e..0000000 --- a/cmd/imapsql-ctl/appendlimit.go +++ /dev/null @@ -1,35 +0,0 @@ -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 -} diff --git a/cmd/imapsql-ctl/config.go b/cmd/imapsql-ctl/config.go deleted file mode 100644 index 1cd0585..0000000 --- a/cmd/imapsql-ctl/config.go +++ /dev/null @@ -1,65 +0,0 @@ -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 -} diff --git a/cmd/imapsql-ctl/flags.go b/cmd/imapsql-ctl/flags.go deleted file mode 100644 index 3ec41a6..0000000 --- a/cmd/imapsql-ctl/flags.go +++ /dev/null @@ -1,65 +0,0 @@ -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) -} diff --git a/cmd/imapsql-ctl/main.go b/cmd/imapsql-ctl/main.go deleted file mode 100644 index 173c0c9..0000000 --- a/cmd/imapsql-ctl/main.go +++ /dev/null @@ -1,428 +0,0 @@ -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 \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) - } -} diff --git a/cmd/imapsql-ctl/mboxes.go b/cmd/imapsql-ctl/mboxes.go deleted file mode 100644 index a3850d8..0000000 --- a/cmd/imapsql-ctl/mboxes.go +++ /dev/null @@ -1,210 +0,0 @@ -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 -} diff --git a/cmd/imapsql-ctl/mysql.go b/cmd/imapsql-ctl/mysql.go deleted file mode 100644 index 072ebf9..0000000 --- a/cmd/imapsql-ctl/mysql.go +++ /dev/null @@ -1,3 +0,0 @@ -package main - -import _ "github.com/go-sql-driver/mysql" diff --git a/cmd/imapsql-ctl/postgresql.go b/cmd/imapsql-ctl/postgresql.go deleted file mode 100644 index d2d091d..0000000 --- a/cmd/imapsql-ctl/postgresql.go +++ /dev/null @@ -1,3 +0,0 @@ -package main - -import _ "github.com/lib/pq" diff --git a/cmd/imapsql-ctl/users.go b/cmd/imapsql-ctl/users.go deleted file mode 100644 index 6a6397e..0000000 --- a/cmd/imapsql-ctl/users.go +++ /dev/null @@ -1,189 +0,0 @@ -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 -} diff --git a/cmd/imapsql-ctl/version.go b/cmd/imapsql-ctl/version.go deleted file mode 100644 index ff4051f..0000000 --- a/cmd/imapsql-ctl/version.go +++ /dev/null @@ -1,25 +0,0 @@ -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) -} diff --git a/cmd/maddyctl/appendlimit.go b/cmd/maddyctl/appendlimit.go new file mode 100644 index 0000000..3fe4d25 --- /dev/null +++ b/cmd/maddyctl/appendlimit.go @@ -0,0 +1,131 @@ +package main + +import ( + "errors" + "fmt" + + appendlimit "github.com/emersion/go-imap-appendlimit" + "github.com/urfave/cli" +) + +// Copied from go-imap-backend-tests. + +// AppendLimitStorage is extension for main backend interface (backend.Storage) which +// allows to set append limit value for testing and administration purposes. +type AppendLimitStorage 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 +} + +func mboxesAppendLimit(be Storage, ctx *cli.Context) error { + 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 := be.GetUser(username) + if err != nil { + return err + } + + mbox, err := u.GetMailbox(name) + if err != nil { + return err + } + + mboxAL, ok := mbox.(AppendLimitMbox) + if !ok { + return errors.New("Error: Storage does not support per-mailbox append limit") + } + + 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 +} + +func usersAppendlimit(be Storage, ctx *cli.Context) error { + username := ctx.Args().First() + if username == "" { + return errors.New("Error: USERNAME is required") + } + + u, err := be.GetUser(username) + if err != nil { + return err + } + userAL, ok := u.(AppendLimitUser) + if !ok { + return errors.New("Error: Storage does not support per-user append limit") + } + + 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 +} diff --git a/cmd/imapsql-ctl/utils.go b/cmd/maddyctl/clitools/clitools.go similarity index 72% rename from cmd/imapsql-ctl/utils.go rename to cmd/maddyctl/clitools/clitools.go index 8000967..5dad9f8 100644 --- a/cmd/imapsql-ctl/utils.go +++ b/cmd/maddyctl/clitools/clitools.go @@ -1,26 +1,13 @@ -package main +package clitools import ( + "bufio" "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, ", ") -} +var stdinScanner = bufio.NewScanner(os.Stdin) func Confirmation(prompt string, def bool) bool { selection := "y/N" @@ -29,12 +16,12 @@ func Confirmation(prompt string, def bool) bool { } fmt.Fprintf(os.Stderr, "%s [%s]: ", prompt, selection) - if !stdinScnr.Scan() { - fmt.Fprintln(os.Stderr, stdinScnr.Err()) + if !stdinScanner.Scan() { + fmt.Fprintln(os.Stderr, stdinScanner.Err()) return false } - switch stdinScnr.Text() { + switch stdinScanner.Text() { case "Y", "y": return true case "N", "n": @@ -102,10 +89,10 @@ func ReadPassword(prompt string) (string, error) { return string(buf), nil } else { - if !stdinScnr.Scan() { - return "", stdinScnr.Err() + if !stdinScanner.Scan() { + return "", stdinScanner.Err() } - return stdinScnr.Text(), nil + return stdinScanner.Text(), nil } } diff --git a/cmd/imapsql-ctl/termios.go b/cmd/maddyctl/clitools/termios.go similarity index 98% rename from cmd/imapsql-ctl/termios.go rename to cmd/maddyctl/clitools/termios.go index 04faa51..80a8ed6 100644 --- a/cmd/imapsql-ctl/termios.go +++ b/cmd/maddyctl/clitools/termios.go @@ -1,6 +1,6 @@ //+build linux -package main +package clitools // Copied from github.com/foxcpp/ttyprompt // Commit 087a574, terminal/termios.go diff --git a/cmd/imapsql-ctl/termios_stub.go b/cmd/maddyctl/clitools/termios_stub.go similarity index 96% rename from cmd/imapsql-ctl/termios_stub.go rename to cmd/maddyctl/clitools/termios_stub.go index a4c8d91..96eba0f 100644 --- a/cmd/imapsql-ctl/termios_stub.go +++ b/cmd/maddyctl/clitools/termios_stub.go @@ -1,6 +1,6 @@ //+build !linux -package main +package clitools import ( "errors" diff --git a/cmd/maddyctl/config.go b/cmd/maddyctl/config.go new file mode 100644 index 0000000..f5072f1 --- /dev/null +++ b/cmd/maddyctl/config.go @@ -0,0 +1,37 @@ +package main + +import ( + "errors" + "os" + + "github.com/foxcpp/maddy/config" + "github.com/foxcpp/maddy/config/parser" +) + +func findBlockInCfg(path, cfgBlock string) (root, block *config.Node, err error) { + f, err := os.Open(path) + nodes, err := parser.Read(f, path) + if err != nil { + return nil, nil, err + } + defer f.Close() + + // Global variables relevant for sql module. + for _, node := range nodes { + if node.Name != "sql" { + continue + } + + if len(node.Args) == 0 && cfgBlock == node.Name { + return &config.Node{Children: nodes}, &node, nil + } + + for _, arg := range node.Args { + if arg == cfgBlock { + return &config.Node{Children: nodes}, &node, nil + } + } + } + + return nil, nil, errors.New("no requested block found in configuration") +} diff --git a/cmd/imapsql-ctl/msgs.go b/cmd/maddyctl/imap.go similarity index 50% rename from cmd/imapsql-ctl/msgs.go rename to cmd/maddyctl/imap.go index 5283042..a2ee9bf 100644 --- a/cmd/imapsql-ctl/msgs.go +++ b/cmd/maddyctl/imap.go @@ -6,22 +6,151 @@ import ( "fmt" "io" "os" + "strings" "time" - eimap "github.com/emersion/go-imap" + "github.com/emersion/go-imap" imapsql "github.com/foxcpp/go-imap-sql" + "github.com/foxcpp/maddy/cmd/maddyctl/clitools" "github.com/urfave/cli" ) -func msgsAdd(ctx *cli.Context) error { - if err := connectToDB(ctx); err != nil { +func FormatAddress(addr *imap.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 mboxesList(be Storage, ctx *cli.Context) error { + username := ctx.Args().First() + if username == "" { + return errors.New("Error: USERNAME is required") + } + + u, err := be.GetUser(username) + if err != nil { return err } - if !ctx.GlobalBool("unsafe") { - return errors.New("Error: Refusing to edit mailboxes without --unsafe") + 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(be Storage, ctx *cli.Context) error { + 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 := be.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(be Storage, ctx *cli.Context) error { + 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 := be.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([]imap.StatusItem{imap.StatusMessages}) + if err != nil { + return err + } + + if status.Messages != 0 { + fmt.Fprintf(os.Stderr, "Mailbox %s contains %d messages.\n", name, status.Messages) + } + + if !clitools.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(be Storage, ctx *cli.Context) error { + 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 := be.GetUser(username) + if err != nil { + return err + } + + return u.RenameMailbox(oldName, newName) +} + +func msgsAdd(be Storage, ctx *cli.Context) error { username := ctx.Args().First() if username == "" { return errors.New("Error: USERNAME is required") @@ -31,7 +160,7 @@ func msgsAdd(ctx *cli.Context) error { return errors.New("Error: MAILBOX is required") } - u, err := backend.GetUser(username) + u, err := be.GetUser(username) if err != nil { return err } @@ -60,7 +189,7 @@ func msgsAdd(ctx *cli.Context) error { return errors.New("Error: Empty message, refusing to continue") } - status, err := mbox.Status([]eimap.StatusItem{eimap.StatusUidNext}) + status, err := mbox.Status([]imap.StatusItem{imap.StatusUidNext}) if err != nil { return err } @@ -74,15 +203,7 @@ func msgsAdd(ctx *cli.Context) error { 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") - } - +func msgsRemove(be Storage, ctx *cli.Context) error { username := ctx.Args().First() if username == "" { return errors.New("Error: USERNAME is required") @@ -96,12 +217,12 @@ func msgsRemove(ctx *cli.Context) error { return errors.New("Error: SEQSET is required") } - seq, err := eimap.ParseSeqSet(seqset) + seq, err := imap.ParseSeqSet(seqset) if err != nil { return err } - u, err := backend.GetUser(username) + u, err := be.GetUser(username) if err != nil { return err } @@ -112,7 +233,8 @@ func msgsRemove(ctx *cli.Context) error { } if !ctx.Bool("yes") { - if !Confirmation("Are you sure you want to delete these messages?", false) { + fmt.Fprintf(os.Stderr, "Currently, it is unsafe to remove messages from mailboxes used by connected clients, continue?") + if !clitools.Confirmation("Are you sure you want to delete these messages?", false) { return errors.New("Cancelled") } } @@ -125,11 +247,7 @@ func msgsRemove(ctx *cli.Context) error { return nil } -func msgsCopy(ctx *cli.Context) error { - if err := connectToDB(ctx); err != nil { - return err - } - +func msgsCopy(be Storage, ctx *cli.Context) error { if !ctx.GlobalBool("unsafe") { return errors.New("Error: Refusing to edit mailboxes without --unsafe") } @@ -151,12 +269,12 @@ func msgsCopy(ctx *cli.Context) error { return errors.New("Error: TGTMAILBOX is required") } - seq, err := eimap.ParseSeqSet(seqset) + seq, err := imap.ParseSeqSet(seqset) if err != nil { return err } - u, err := backend.GetUser(username) + u, err := be.GetUser(username) if err != nil { return err } @@ -169,13 +287,9 @@ func msgsCopy(ctx *cli.Context) error { 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") +func msgsMove(be Storage, ctx *cli.Context) error { + if ctx.Bool("y,yes") || !clitools.Confirmation("Currently, it is unsafe to remove messages from mailboxes used by connected clients, continue?", false) { + return errors.New("Cancelled") } username := ctx.Args().First() @@ -195,12 +309,12 @@ func msgsMove(ctx *cli.Context) error { return errors.New("Error: TGTMAILBOX is required") } - seq, err := eimap.ParseSeqSet(seqset) + seq, err := imap.ParseSeqSet(seqset) if err != nil { return err } - u, err := backend.GetUser(username) + u, err := be.GetUser(username) if err != nil { return err } @@ -215,11 +329,7 @@ func msgsMove(ctx *cli.Context) error { return moveMbox.MoveMessages(ctx.Bool("uid"), seq, tgtName) } -func msgsList(ctx *cli.Context) error { - if err := connectToDB(ctx); err != nil { - return err - } - +func msgsList(be Storage, ctx *cli.Context) error { username := ctx.Args().First() if username == "" { return errors.New("Error: USERNAME is required") @@ -233,12 +343,12 @@ func msgsList(ctx *cli.Context) error { seqset = "*" } - seq, err := eimap.ParseSeqSet(seqset) + seq, err := imap.ParseSeqSet(seqset) if err != nil { return err } - u, err := backend.GetUser(username) + u, err := be.GetUser(username) if err != nil { return err } @@ -248,9 +358,9 @@ func msgsList(ctx *cli.Context) error { return err } - ch := make(chan *eimap.Message, 10) + ch := make(chan *imap.Message, 10) go func() { - err = mbox.ListMessages(ctx.Bool("uid"), seq, []eimap.FetchItem{eimap.FetchEnvelope, eimap.FetchInternalDate, eimap.FetchRFC822Size, eimap.FetchFlags, eimap.FetchUid}, ch) + err = mbox.ListMessages(ctx.Bool("uid"), seq, []imap.FetchItem{imap.FetchEnvelope, imap.FetchInternalDate, imap.FetchRFC822Size, imap.FetchFlags, imap.FetchUid}, ch) }() for msg := range ch { @@ -295,11 +405,7 @@ func msgsList(ctx *cli.Context) error { return err } -func msgsDump(ctx *cli.Context) error { - if err := connectToDB(ctx); err != nil { - return err - } - +func msgsDump(be Storage, ctx *cli.Context) error { username := ctx.Args().First() if username == "" { return errors.New("Error: USERNAME is required") @@ -313,12 +419,12 @@ func msgsDump(ctx *cli.Context) error { seqset = "*" } - seq, err := eimap.ParseSeqSet(seqset) + seq, err := imap.ParseSeqSet(seqset) if err != nil { return err } - u, err := backend.GetUser(username) + u, err := be.GetUser(username) if err != nil { return err } @@ -328,9 +434,9 @@ func msgsDump(ctx *cli.Context) error { return err } - ch := make(chan *eimap.Message, 10) + ch := make(chan *imap.Message, 10) go func() { - err = mbox.ListMessages(ctx.Bool("uid"), seq, []eimap.FetchItem{eimap.FetchRFC822}, ch) + err = mbox.ListMessages(ctx.Bool("uid"), seq, []imap.FetchItem{imap.FetchRFC822}, ch) }() for msg := range ch { @@ -342,3 +448,52 @@ func msgsDump(ctx *cli.Context) error { } return err } + +func msgsFlags(be Storage, ctx *cli.Context) error { + 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 := be.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) +} diff --git a/cmd/maddyctl/main.go b/cmd/maddyctl/main.go new file mode 100644 index 0000000..43592d4 --- /dev/null +++ b/cmd/maddyctl/main.go @@ -0,0 +1,635 @@ +package main + +import ( + "errors" + "fmt" + "os" + + "github.com/emersion/go-imap/backend" + "github.com/urfave/cli" + "golang.org/x/crypto/bcrypt" +) + +type UserDB interface { + ListUsers() ([]string, error) + CreateUser(username, password string) error + CreateUserNoPass(username string) error + DeleteUser(username string) error + SetUserPassword(username, newPassword string) error + Close() error +} + +type Storage interface { + GetUser(username string) (backend.User, error) + Close() error +} + +func main() { + app := cli.NewApp() + app.Name = "maddyctl" + app.Usage = "maddy mail server administration utility" + app.Version = buildInfo() + + 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", + }, + } + + app.Commands = []cli.Command{ + { + Name: "users", + Usage: "User accounts management", + Subcommands: []cli.Command{ + { + Name: "list", + Usage: "List created user accounts", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVar: "MADDY_CFGBLOCK", + Value: "local_authdb", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openUserDB(ctx) + if err != nil { + return err + } + defer be.Close() + return usersList(be, ctx) + }, + }, + { + Name: "create", + Usage: "Create user account", + Description: "Reads password from stdin", + ArgsUsage: "USERNAME", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVar: "MADDY_CFGBLOCK", + Value: "local_authdb", + }, + 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: func(ctx *cli.Context) error { + be, err := openUserDB(ctx) + if err != nil { + return err + } + defer be.Close() + return usersCreate(be, ctx) + }, + }, + { + Name: "remove", + Usage: "Delete user account", + ArgsUsage: "USERNAME", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVar: "MADDY_CFGBLOCK", + Value: "local_authdb", + }, + cli.BoolFlag{ + Name: "yes,y", + Usage: "Don't ask for confirmation", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openUserDB(ctx) + if err != nil { + return err + } + defer be.Close() + return usersRemove(be, ctx) + }, + }, + { + Name: "password", + Usage: "Change account password", + Description: "Reads password from stdin", + ArgsUsage: "USERNAME", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVar: "MADDY_CFGBLOCK", + Value: "local_authdb", + }, + 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 for password. Supported values vary depending on storage backend.", + Value: "", + }, + cli.IntFlag{ + Name: "bcrypt-cost", + Usage: "Specify bcrypt cost value", + Value: bcrypt.DefaultCost, + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openUserDB(ctx) + if err != nil { + return err + } + defer be.Close() + return usersPassword(be, ctx) + }, + }, + { + Name: "imap-appendlimit", + Usage: "Query or set user's APPENDLIMIT value", + ArgsUsage: "USERNAME", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVar: "MADDY_CFGBLOCK", + Value: "local_mailboxes", + }, + cli.IntFlag{ + Name: "value,v", + Usage: "Set APPENDLIMIT to specified value (in bytes)", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer be.Close() + return usersAppendlimit(be, ctx) + }, + }, + }, + }, + { + Name: "imap-mboxes", + Usage: "IMAP mailboxes (folders) management", + Subcommands: []cli.Command{ + { + Name: "list", + Usage: "Show mailboxes of user", + ArgsUsage: "USERNAME", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVar: "MADDY_CFGBLOCK", + Value: "local_mailboxes", + }, + cli.BoolFlag{ + Name: "subscribed,s", + Usage: "List only subscribed mailboxes", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer be.Close() + return mboxesList(be, ctx) + }, + }, + { + Name: "create", + Usage: "Create mailbox", + ArgsUsage: "USERNAME NAME", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVar: "MADDY_CFGBLOCK", + Value: "local_mailboxes", + }, + cli.StringFlag{ + Name: "special", + Usage: "Set SPECIAL-USE attribute on mailbox; valid values: archive, drafts, junk, sent, trash", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer be.Close() + return mboxesCreate(be, ctx) + }, + }, + { + Name: "remove", + Usage: "Remove mailbox", + Description: "WARNING: All contents of mailbox will be irrecoverably lost.", + ArgsUsage: "USERNAME MAILBOX", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVar: "MADDY_CFGBLOCK", + Value: "local_mailboxes", + }, + cli.BoolFlag{ + Name: "yes,y", + Usage: "Don't ask for confirmation", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer be.Close() + return mboxesRemove(be, ctx) + }, + }, + { + Name: "rename", + Usage: "Rename mailbox", + Description: "Rename may cause unexpected failures on client-side so be careful.", + ArgsUsage: "USERNAME OLDNAME NEWNAME", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVar: "MADDY_CFGBLOCK", + Value: "local_mailboxes", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer be.Close() + return mboxesRename(be, ctx) + }, + }, + }, + }, + { + Name: "imap-msgs", + Usage: "IMAP messages management", + Subcommands: []cli.Command{ + { + Name: "add", + Usage: "Add message to mailbox", + ArgsUsage: "USERNAME MAILBOX", + Description: "Reads message body (with headers) from stdin. Prints UID of created message on success.", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVar: "MADDY_CFGBLOCK", + Value: "local_mailboxes", + }, + 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: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer be.Close() + return msgsAdd(be, ctx) + }, + }, + { + Name: "add-flags", + Usage: "Add flags to messages", + ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...", + Description: "Add flags to all messages matched by SEQ.", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVar: "MADDY_CFGBLOCK", + Value: "local_mailboxes", + }, + cli.BoolFlag{ + Name: "uid,u", + Usage: "Use UIDs for SEQSET instead of sequence numbers", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer be.Close() + return msgsFlags(be, ctx) + }, + }, + { + Name: "rem-flags", + Usage: "Remove flags from messages", + ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...", + Description: "Remove flags from all messages matched by SEQ.", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVar: "MADDY_CFGBLOCK", + Value: "local_mailboxes", + }, + cli.BoolFlag{ + Name: "uid,u", + Usage: "Use UIDs for SEQSET instead of sequence numbers", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer be.Close() + return msgsFlags(be, ctx) + }, + }, + { + Name: "set-flags", + Usage: "Set flags on messages", + ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...", + Description: "Set flags on all messages matched by SEQ.", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVar: "MADDY_CFGBLOCK", + Value: "local_mailboxes", + }, + cli.BoolFlag{ + Name: "uid,u", + Usage: "Use UIDs for SEQSET instead of sequence numbers", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer be.Close() + return msgsFlags(be, ctx) + }, + }, + { + Name: "remove", + Usage: "Remove messages from mailbox", + ArgsUsage: "USERNAME MAILBOX SEQSET", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVar: "MADDY_CFGBLOCK", + Value: "local_mailboxes", + }, + 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: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer be.Close() + return msgsRemove(be, ctx) + }, + }, + { + Name: "copy", + Usage: "Copy messages between mailboxes", + 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.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVar: "MADDY_CFGBLOCK", + Value: "local_mailboxes", + }, + cli.BoolFlag{ + Name: "uid,u", + Usage: "Use UIDs for SEQSET instead of sequence numbers", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer be.Close() + return msgsCopy(be, ctx) + }, + }, + { + Name: "move", + Usage: "Move messages between mailboxes", + 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.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVar: "MADDY_CFGBLOCK", + Value: "local_mailboxes", + }, + 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: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer be.Close() + return msgsMove(be, ctx) + }, + }, + { + 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.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVar: "MADDY_CFGBLOCK", + Value: "local_mailboxes", + }, + 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: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer be.Close() + return msgsList(be, ctx) + }, + }, + { + 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.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVar: "MADDY_CFGBLOCK", + Value: "local_mailboxes", + }, + cli.BoolFlag{ + Name: "uid,u", + Usage: "Use UIDs for SEQ instead of sequence numbers", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer be.Close() + return msgsList(be, ctx) + }, + }, + }, + }, + } + + if err := app.Run(os.Args); err != nil { + fmt.Fprintln(os.Stderr, err) + } +} + +func openStorage(ctx *cli.Context) (Storage, error) { + cfgPath := ctx.GlobalString("config") + if cfgPath == "" { + return nil, errors.New("Error: config is required") + } + + statePath := ctx.GlobalString("state") + if cfgPath == "" { + return nil, errors.New("Error: state is required") + } + + cfgBlock := ctx.String("cfg-block") + if cfgBlock == "" { + return nil, errors.New("Error: cfg-block is required") + } + + root, node, err := findBlockInCfg(cfgPath, cfgBlock) + if err != nil { + return nil, err + } + + if err := os.Chdir(statePath); err != nil { + return nil, err + } + + switch node.Name { + case "sql": + return sqlFromCfgBlock(root, node) + default: + return nil, errors.New("Error: Storage backend is not supported by maddyctl") + } +} + +func openUserDB(ctx *cli.Context) (UserDB, error) { + cfgPath := ctx.GlobalString("config") + if cfgPath == "" { + return nil, errors.New("Error: config is required") + } + + statePath := ctx.GlobalString("state") + if cfgPath == "" { + return nil, errors.New("Error: state is required") + } + + cfgBlock := ctx.String("cfg-block") + if cfgBlock == "" { + return nil, errors.New("Error: cfg-block is required") + } + + root, node, err := findBlockInCfg(cfgPath, cfgBlock) + if err != nil { + return nil, err + } + + if err := os.Chdir(statePath); err != nil { + return nil, err + } + + switch node.Name { + case "sql": + return sqlFromCfgBlock(root, node) + default: + return nil, errors.New("Error: Authentication backend is not supported by maddyctl") + } +} diff --git a/cmd/maddyctl/sql.go b/cmd/maddyctl/sql.go new file mode 100644 index 0000000..0a691e5 --- /dev/null +++ b/cmd/maddyctl/sql.go @@ -0,0 +1,37 @@ +package main + +import ( + imapsql "github.com/foxcpp/go-imap-sql" + "github.com/foxcpp/maddy/config" + "github.com/foxcpp/maddy/storage/sql" + + _ "github.com/go-sql-driver/mysql" + _ "github.com/lib/pq" +) + +func sqlFromCfgBlock(root, node *config.Node) (*imapsql.Backend, error) { + // Global variables relevant for sql module. + globals := config.NewMap(nil, root) + 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 + } + + 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.Values, node)); err != nil { + return nil, err + } + + return mod.(*sql.Storage).Back, nil +} diff --git a/cmd/imapsql-ctl/sqlite3.go b/cmd/maddyctl/sqlite3.go similarity index 68% rename from cmd/imapsql-ctl/sqlite3.go rename to cmd/maddyctl/sqlite3.go index 8ad8bf7..db09b1c 100644 --- a/cmd/imapsql-ctl/sqlite3.go +++ b/cmd/maddyctl/sqlite3.go @@ -1,4 +1,4 @@ -// +build cgo +// +build cgo,!nosqlite3 package main diff --git a/cmd/maddyctl/users.go b/cmd/maddyctl/users.go new file mode 100644 index 0000000..88d5b04 --- /dev/null +++ b/cmd/maddyctl/users.go @@ -0,0 +1,152 @@ +package main + +import ( + "errors" + "fmt" + "os" + + imapsql "github.com/foxcpp/go-imap-sql" + "github.com/foxcpp/maddy/cmd/maddyctl/clitools" + "github.com/urfave/cli" + "golang.org/x/crypto/bcrypt" +) + +func usersList(be UserDB, ctx *cli.Context) error { + list, err := be.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(be UserDB, ctx *cli.Context) error { + username := ctx.Args().First() + if username == "" { + return errors.New("Error: USERNAME is required") + } + + if ctx.IsSet("null") { + return be.CreateUserNoPass(username) + } + + if ctx.IsSet("hash") { + // XXX: This needs to be updated to work with other backends in future. + sqlbe, ok := be.(*imapsql.Backend) + if !ok { + return errors.New("Error: Storage does not support custom hash functions") + } + sqlbe.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") + } + + // XXX: This needs to be updated to work with other backends in future. + sqlbe, ok := be.(*imapsql.Backend) + if !ok { + return errors.New("Error: Storage does not support custom hash cost") + } + + sqlbe.Opts.BcryptCost = ctx.Int("bcrypt-cost") + } + + var pass string + if ctx.IsSet("password") { + pass = ctx.String("password,p") + } else { + var err error + pass, err = clitools.ReadPassword("Enter password for new user") + if err != nil { + return err + } + } + + return be.CreateUser(username, pass) +} + +func usersRemove(be UserDB, ctx *cli.Context) error { + 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") + } + + if !ctx.Bool("yes") { + if !clitools.Confirmation("Are you sure you want to delete this user account?", false) { + return errors.New("Cancelled") + } + } + + return be.DeleteUser(username) +} + +func usersPassword(be UserDB, ctx *cli.Context) error { + username := ctx.Args().First() + if username == "" { + return errors.New("Error: USERNAME is required") + } + + if ctx.IsSet("null") { + type ResetPassBack interface { + ResetPassword(string) error + } + rpbe, ok := be.(ResetPassBack) + if !ok { + return errors.New("Error: Storage does not support null passwords") + } + return rpbe.ResetPassword(username) + } + + if ctx.IsSet("hash") { + // XXX: This needs to be updated to work with other backends in future. + sqlbe, ok := be.(*imapsql.Backend) + if !ok { + return errors.New("Error: Storage does not support custom hash functions") + } + sqlbe.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") + } + + // XXX: This needs to be updated to work with other backends in future. + sqlbe, ok := be.(*imapsql.Backend) + if !ok { + return errors.New("Error: Storage does not support custom hash cost") + } + + sqlbe.Opts.BcryptCost = ctx.Int("bcrypt-cost") + } + + var pass string + if ctx.IsSet("password") { + pass = ctx.String("password") + } else { + var err error + pass, err = clitools.ReadPassword("Enter new password") + if err != nil { + return err + } + } + + return be.SetUserPassword(username, pass) +} diff --git a/cmd/maddyctl/version.go b/cmd/maddyctl/version.go new file mode 100644 index 0000000..7695d8c --- /dev/null +++ b/cmd/maddyctl/version.go @@ -0,0 +1,16 @@ +package main + +import ( + "fmt" + "runtime/debug" +) + +func buildInfo() string { + if info, ok := debug.ReadBuildInfo(); ok { + if info.Main.Version == "(devel)" { + return "unknown (built from source tree)" + } + return fmt.Sprintf("%s %s", info.Main.Version, info.Main.Sum) + } + return "unknown (GOPATH build)" +} diff --git a/storage/sql/sql.go b/storage/sql/sql.go index aee9a00..2de0a9c 100644 --- a/storage/sql/sql.go +++ b/storage/sql/sql.go @@ -297,6 +297,10 @@ func (store *Storage) GetOrCreateUser(username string) (backend.User, error) { return store.Back.GetOrCreateUser(accountName) } +func (store *Storage) Close() error { + return store.Back.Close() +} + func init() { module.Register("sql", New) }