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.
This commit is contained in:
fox.cpp 2019-10-20 01:49:35 +03:00
parent 547f35d41f
commit ae6decd876
No known key found for this signature in database
GPG key ID: E76D97CCEDE90B6C
24 changed files with 1241 additions and 1130 deletions

2
.gitignore vendored
View file

@ -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

View file

@ -149,7 +149,7 @@ You need to change the following directives to your values:
With that configuration you will get the following:
- SQLite-based storage for messages
- Authentication using SQLite-based virtual users DB (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

View file

@ -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.

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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 <fox.cpp@disroot.org>\n Published under the terms of the MIT license (https://opensource.org/licenses/MIT)"
app.Usage = "SQL database management utility for maddy"
app.Version = buildInfo()
app.After = closeBackend
app.Flags = []cli.Flag{
cli.StringFlag{
Name: "config",
Usage: "Configuration file to use",
EnvVar: "MADDY_CONFIG",
Value: "/etc/maddy/maddy.conf",
},
cli.StringFlag{
Name: "state",
Usage: "State directory to use",
EnvVar: "MADDY_STATE",
Value: "/var/lib/maddy",
},
cli.StringFlag{
Name: "cfg-block",
Usage: "SQL module configuration to use",
EnvVar: "MADDY_CFGBLOCK",
Value: "local_mailboxes",
},
cli.StringFlag{
Name: "driver",
Usage: "Directly specify driver value instead of reading it from config",
EnvVar: "MADDY_SQL_DRIVER",
Value: "",
},
cli.StringFlag{
Name: "dsn",
Usage: "Directly specify dsn value instead of reading it from config",
EnvVar: "MADDY_SQL_DSN",
Value: "",
},
cli.StringFlag{
Name: "fsstore",
Usage: "Directly specify fsstore value instead of reading it from config",
EnvVar: "MADDY_SQL_FSSTORE",
Value: "",
},
cli.BoolFlag{
Name: "quiet,q",
Usage: "Don't print user-friendly messages to stderr",
},
cli.BoolFlag{
Name: "unsafe",
Usage: "Allow to perform actions that can be safely done only without running server",
},
}
app.Commands = []cli.Command{
{
Name: "mboxes",
Usage: "Mailboxes (folders) management",
Subcommands: []cli.Command{
{
Name: "list",
Usage: "Show mailboxes of user",
ArgsUsage: "USERNAME",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "subscribed,s",
Usage: "List only subscribed mailboxes",
},
},
Action: mboxesList,
},
{
Name: "create",
Usage: "Create mailbox",
ArgsUsage: "USERNAME NAME",
Action: mboxesCreate,
Flags: []cli.Flag{
cli.StringFlag{
Name: "special",
Usage: "Set SPECIAL-USE attribute on mailbox; valid values: archive, drafts, junk, sent, trash",
},
},
},
{
Name: "remove",
Usage: "Remove mailbox (requires --unsafe)",
Description: "WARNING: All contents of mailbox will be irrecoverably lost.",
ArgsUsage: "USERNAME MAILBOX",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "yes,y",
Usage: "Don't ask for confirmation",
},
},
Action: mboxesRemove,
},
{
Name: "rename",
Usage: "Rename mailbox (requires --unsafe)",
Description: "Rename may cause unexpected failures on client-side so be careful.",
ArgsUsage: "USERNAME OLDNAME NEWNAME",
Action: mboxesRename,
},
{
Name: "appendlimit",
Usage: "Query or set user's APPENDLIMIT value",
ArgsUsage: "USERNAME",
Flags: []cli.Flag{
cli.IntFlag{
Name: "value,v",
Usage: "Set APPENDLIMIT to specified value (in bytes). Pass -1 to disable limit.",
},
},
Action: mboxesAppendLimit,
},
},
},
{
Name: "msgs",
Usage: "Messages management",
Subcommands: []cli.Command{
{
Name: "add",
Usage: "Add message to mailbox (requires --unsafe)",
ArgsUsage: "USERNAME MAILBOX",
Description: "Reads message body (with headers) from stdin. Prints UID of created message on success.",
Flags: []cli.Flag{
cli.StringSliceFlag{
Name: "flag,f",
Usage: "Add flag to message. Can be specified multiple times",
},
cli.Int64Flag{
Name: "date,d",
Usage: "Set internal date value to specified UNIX timestamp",
},
},
Action: msgsAdd,
},
{
Name: "add-flags",
Usage: "Add flags to messages (requires --unsafe)",
ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...",
Description: "Add flags to all messages matched by SEQ.",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "uid,u",
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
},
Action: msgsFlags,
},
{
Name: "rem-flags",
Usage: "Remove flags from messages (requires --unsafe)",
ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...",
Description: "Remove flags from all messages matched by SEQ.",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "uid,u",
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
},
Action: msgsFlags,
},
{
Name: "set-flags",
Usage: "Set flags on messages (requires --unsafe)",
ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...",
Description: "Set flags on all messages matched by SEQ.",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "uid,u",
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
},
Action: msgsFlags,
},
{
Name: "remove",
Usage: "Remove messages from mailbox (requires --unsafe)",
ArgsUsage: "USERNAME MAILBOX SEQSET",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "uid,u",
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
cli.BoolFlag{
Name: "yes,y",
Usage: "Don't ask for confirmation",
},
},
Action: msgsRemove,
},
{
Name: "copy",
Usage: "Copy messages between mailboxes (requires --unsafe)",
Description: "Note: You can't copy between mailboxes of different users. APPENDLIMIT of target mailbox is not enforced.",
ArgsUsage: "USERNAME SRCMAILBOX SEQSET TGTMAILBOX",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "uid,u",
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
},
Action: msgsCopy,
},
{
Name: "move",
Usage: "Move messages between mailboxes (requires --unsafe)",
Description: "Note: You can't move between mailboxes of different users. APPENDLIMIT of target mailbox is not enforced.",
ArgsUsage: "USERNAME SRCMAILBOX SEQSET TGTMAILBOX",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "uid,u",
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
},
Action: msgsMove,
},
{
Name: "list",
Usage: "List messages in mailbox",
Description: "If SEQSET is specified - only show messages that match it.",
ArgsUsage: "USERNAME MAILBOX [SEQSET]",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "uid,u",
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
cli.BoolFlag{
Name: "full,f",
Usage: "Show entire envelope and all server meta-data",
},
},
Action: msgsList,
},
{
Name: "dump",
Usage: "Dump message body",
Description: "If passed SEQ matches multiple messages - they will be joined.",
ArgsUsage: "USERNAME MAILBOX SEQ",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "uid,u",
Usage: "Use UIDs for SEQ instead of sequence numbers",
},
},
Action: msgsDump,
},
},
},
{
Name: "users",
Usage: "User accounts management",
Subcommands: []cli.Command{
{
Name: "list",
Usage: "List created user accounts",
Action: usersList,
},
{
Name: "create",
Usage: "Create user account",
Description: "Reads password from stdin",
ArgsUsage: "USERNAME",
Flags: []cli.Flag{
cli.StringFlag{
Name: "password,p",
Usage: "Use `PASSWORD instead of reading password from stdin.\n\t\tWARNING: Provided only for debugging convenience. Don't leave your passwords in shell history!",
},
cli.BoolFlag{
Name: "null,n",
Usage: "Create account with null password",
},
cli.StringFlag{
Name: "hash",
Usage: "Use specified hash algorithm. Valid values: sha3-512, bcrypt",
Value: "bcrypt",
},
cli.IntFlag{
Name: "bcrypt-cost",
Usage: "Specify bcrypt cost value",
Value: bcrypt.DefaultCost,
},
},
Action: usersCreate,
},
{
Name: "remove",
Usage: "Delete user account (requires --unsafe)",
ArgsUsage: "USERNAME",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "yes,y",
Usage: "Don't ask for confirmation",
},
},
Action: usersRemove,
},
{
Name: "password",
Usage: "Change account password",
Description: "Reads password from stdin",
ArgsUsage: "USERNAME",
Flags: []cli.Flag{
cli.StringFlag{
Name: "password,p",
Usage: "Use `PASSWORD` instead of reading password from stdin.\n\t\tWARNING: Provided only for debugging convenience. Don't leave your passwords in shell history!",
},
cli.BoolFlag{
Name: "null,n",
Usage: "Set password to null",
},
cli.StringFlag{
Name: "hash",
Usage: "Use specified hash algorithm. Valid values: sha3-512, bcrypt",
Value: "sha3-512",
},
cli.IntFlag{
Name: "bcrypt-cost",
Usage: "Specify bcrypt cost value",
Value: bcrypt.DefaultCost,
},
},
Action: usersPassword,
},
{
Name: "appendlimit",
Usage: "Query or set user's APPENDLIMIT value",
ArgsUsage: "USERNAME",
Flags: []cli.Flag{
cli.IntFlag{
Name: "value,v",
Usage: "Set APPENDLIMIT to specified value (in bytes)",
},
},
Action: usersAppendLimit,
},
},
},
}
if err := app.Run(os.Args); err != nil {
fmt.Fprintln(os.Stderr, err)
}
}

View file

@ -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
}

View file

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

View file

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

View file

@ -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
}

View file

@ -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)
}

131
cmd/maddyctl/appendlimit.go Normal file
View file

@ -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
}

View file

@ -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
}
}

View file

@ -1,6 +1,6 @@
//+build linux
package main
package clitools
// Copied from github.com/foxcpp/ttyprompt
// Commit 087a574, terminal/termios.go

View file

@ -1,6 +1,6 @@
//+build !linux
package main
package clitools
import (
"errors"

37
cmd/maddyctl/config.go Normal file
View file

@ -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")
}

View file

@ -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)
}

635
cmd/maddyctl/main.go Normal file
View file

@ -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")
}
}

37
cmd/maddyctl/sql.go Normal file
View file

@ -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
}

View file

@ -1,4 +1,4 @@
// +build cgo
// +build cgo,!nosqlite3
package main

152
cmd/maddyctl/users.go Normal file
View file

@ -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)
}

16
cmd/maddyctl/version.go Normal file
View file

@ -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)"
}

View file

@ -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)
}