diff --git a/.gitignore b/.gitignore index 8dc59ee..7080732 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ _testmain.go # Compiled binaries cmd/maddy/maddy +cmd/imapsql-ctl/imapsql-ctl cmd/maddy-*-helper/maddy-*-helper # Config files diff --git a/README.md b/README.md index ea39052..fbc8b0d 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 (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 diff --git a/cmd/imapsql-ctl/README.md b/cmd/imapsql-ctl/README.md new file mode 100644 index 0000000..5f6f82e --- /dev/null +++ b/cmd/imapsql-ctl/README.md @@ -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. diff --git a/cmd/imapsql-ctl/appendlimit.go b/cmd/imapsql-ctl/appendlimit.go new file mode 100644 index 0000000..93cab7e --- /dev/null +++ b/cmd/imapsql-ctl/appendlimit.go @@ -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 +} diff --git a/cmd/imapsql-ctl/config.go b/cmd/imapsql-ctl/config.go new file mode 100644 index 0000000..1cd0585 --- /dev/null +++ b/cmd/imapsql-ctl/config.go @@ -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 +} diff --git a/cmd/imapsql-ctl/flags.go b/cmd/imapsql-ctl/flags.go new file mode 100644 index 0000000..3ec41a6 --- /dev/null +++ b/cmd/imapsql-ctl/flags.go @@ -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) +} diff --git a/cmd/imapsql-ctl/main.go b/cmd/imapsql-ctl/main.go new file mode 100644 index 0000000..173c0c9 --- /dev/null +++ b/cmd/imapsql-ctl/main.go @@ -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 \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 new file mode 100644 index 0000000..a3850d8 --- /dev/null +++ b/cmd/imapsql-ctl/mboxes.go @@ -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 +} diff --git a/cmd/imapsql-ctl/msgs.go b/cmd/imapsql-ctl/msgs.go new file mode 100644 index 0000000..5283042 --- /dev/null +++ b/cmd/imapsql-ctl/msgs.go @@ -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 +} diff --git a/cmd/imapsql-ctl/mysql.go b/cmd/imapsql-ctl/mysql.go new file mode 100644 index 0000000..072ebf9 --- /dev/null +++ b/cmd/imapsql-ctl/mysql.go @@ -0,0 +1,3 @@ +package main + +import _ "github.com/go-sql-driver/mysql" diff --git a/cmd/imapsql-ctl/postgresql.go b/cmd/imapsql-ctl/postgresql.go new file mode 100644 index 0000000..d2d091d --- /dev/null +++ b/cmd/imapsql-ctl/postgresql.go @@ -0,0 +1,3 @@ +package main + +import _ "github.com/lib/pq" diff --git a/cmd/imapsql-ctl/sqlite3.go b/cmd/imapsql-ctl/sqlite3.go new file mode 100644 index 0000000..8ad8bf7 --- /dev/null +++ b/cmd/imapsql-ctl/sqlite3.go @@ -0,0 +1,5 @@ +// +build cgo + +package main + +import _ "github.com/mattn/go-sqlite3" diff --git a/cmd/imapsql-ctl/termios.go b/cmd/imapsql-ctl/termios.go new file mode 100644 index 0000000..04faa51 --- /dev/null +++ b/cmd/imapsql-ctl/termios.go @@ -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 +} diff --git a/cmd/imapsql-ctl/termios_stub.go b/cmd/imapsql-ctl/termios_stub.go new file mode 100644 index 0000000..a4c8d91 --- /dev/null +++ b/cmd/imapsql-ctl/termios_stub.go @@ -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") +} diff --git a/cmd/imapsql-ctl/users.go b/cmd/imapsql-ctl/users.go new file mode 100644 index 0000000..6a6397e --- /dev/null +++ b/cmd/imapsql-ctl/users.go @@ -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 +} diff --git a/cmd/imapsql-ctl/utils.go b/cmd/imapsql-ctl/utils.go new file mode 100644 index 0000000..8000967 --- /dev/null +++ b/cmd/imapsql-ctl/utils.go @@ -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 + } +} diff --git a/cmd/imapsql-ctl/version.go b/cmd/imapsql-ctl/version.go new file mode 100644 index 0000000..ff4051f --- /dev/null +++ b/cmd/imapsql-ctl/version.go @@ -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) +} diff --git a/go.mod b/go.mod index abdf33a..a1f36e1 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/storage/sql/bench_test.go b/storage/sql/bench_test.go index 30707f3..2fb2826 100644 --- a/storage/sql/bench_test.go +++ b/storage/sql/bench_test.go @@ -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, } } diff --git a/storage/sql/sql.go b/storage/sql/sql.go index 2a2b212..aee9a00 100644 --- a/storage/sql/sql.go +++ b/storage/sql/sql.go @@ -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() {