From 503c9f8849af59fe0490e78c71feb618da9a4150 Mon Sep 17 00:00:00 2001
From: "fox.cpp" <fox.cpp@disroot.org>
Date: Wed, 29 Jan 2025 21:40:15 +0300
Subject: [PATCH 1/6] Implement Configure-Start-Stop module lifetime and
 non-global Registry object

container.Global is temporarily added until we start passing container
during module initialization.
---
 framework/config/module/modconfig.go          |  30 +--
 framework/container/container.go              |  70 ++++++
 framework/log/log.go                          |  11 +
 framework/module/delivery_target.go           |   6 +-
 framework/module/dummy.go                     |   6 +-
 framework/module/instances.go                 | 105 --------
 framework/module/lifetime.go                  | 121 +++++++++
 framework/module/module.go                    |  26 +-
 framework/module/modules.go                   |  96 ++++++++
 framework/module/mxauth.go                    |   2 +-
 framework/module/partial_delivery.go          |   2 +-
 framework/module/registry.go                  | 162 ++++++++-----
 internal/auth/dovecot_sasl/dovecot_sasl.go    |  20 +-
 internal/auth/external/externalauth.go        |  12 +-
 internal/auth/ldap/ldap.go                    |  32 ++-
 internal/auth/netauth/netauth.go              |  10 +-
 internal/auth/pam/module.go                   |  11 +-
 internal/auth/pass_table/table.go             |  18 +-
 internal/auth/pass_table/table_test.go        |   4 +-
 .../auth/plain_separate/plain_separate.go     |  12 +-
 internal/auth/shadow/module.go                |  11 +-
 .../authorize_sender/authorize_sender.go      |   9 +-
 internal/check/command/command.go             |  18 +-
 internal/check/dkim/dkim.go                   |  11 +-
 internal/check/dkim/dkim_test.go              |   4 +-
 internal/check/dnsbl/dnsbl.go                 |  12 +-
 internal/check/milter/milter.go               |  20 +-
 internal/check/milter/milter_test.go          |   4 +-
 internal/check/rspamd/rspamd.go               |  22 +-
 internal/check/spf/spf.go                     |   4 +-
 internal/check/stateless_check.go             |  11 +-
 internal/cli/ctl/moduleinit.go                |  58 ++---
 .../endpoint/dovecot_sasld/dovecot_sasl.go    |  24 +-
 internal/endpoint/imap/imap.go                |  56 +++--
 internal/endpoint/openmetrics/om.go           |  38 +--
 internal/endpoint/smtp/session.go             |   2 +-
 internal/endpoint/smtp/smtp.go                |  23 +-
 internal/endpoint/smtp/smtp_test.go           |  30 ++-
 internal/endpoint/smtp/smtputf8_test.go       |  20 +-
 internal/endpoint/smtp/submission_test.go     |   2 +-
 internal/imap_filter/command/command.go       |  18 +-
 internal/imap_filter/group.go                 |   4 +-
 internal/libdns/acmedns.go                    |   2 +-
 internal/libdns/alidns.go                     |   2 +-
 internal/libdns/cloudflare.go                 |   2 +-
 internal/libdns/digitalocean.go               |   2 +-
 internal/libdns/gandi.go                      |   2 +-
 internal/libdns/googleclouddns.go             |   2 +-
 internal/libdns/hetzner.go                    |   2 +-
 internal/libdns/leaseweb.go                   |   2 +-
 internal/libdns/metaname.go                   |   2 +-
 internal/libdns/namecheap.go                  |   2 +-
 internal/libdns/namedotcom.go                 |   2 +-
 internal/libdns/provider_module.go            |   2 +-
 internal/libdns/rfc2136.go                    |   2 +-
 internal/libdns/route53.go                    |   2 +-
 internal/libdns/vultr.go                      |   2 +-
 internal/limits/limits.go                     |   4 +-
 internal/modify/dkim/dkim.go                  |  23 +-
 internal/modify/dkim/dkim_test.go             |   4 +-
 internal/modify/group.go                      |   6 +-
 internal/modify/replace_addr.go               |  28 +--
 internal/modify/replace_addr_test.go          |   4 +-
 internal/msgpipeline/check_group.go           |   8 +-
 internal/msgpipeline/dmarc_test.go            |   2 +-
 internal/msgpipeline/module.go                |   4 +-
 internal/msgpipeline/msgpipeline.go           |  10 +-
 internal/msgpipeline/msgpipeline_test.go      |  14 +-
 internal/smtpconn/smtpconn.go                 |   2 +-
 internal/storage/blob/fs/fs.go                |  27 ++-
 internal/storage/blob/s3/s3.go                |  12 +-
 internal/storage/blob/s3/s3_test.go           |   2 +-
 internal/storage/imapsql/delivery.go          |   4 +-
 internal/storage/imapsql/imapsql.go           |  52 ++--
 internal/table/chain.go                       |   4 +-
 internal/table/email_localpart.go             |   4 +-
 internal/table/email_with_domain.go           |   7 +-
 internal/table/file.go                        |  44 ++--
 internal/table/file_test.go                   |  27 ++-
 internal/table/identity.go                    |   4 +-
 internal/table/regexp.go                      |  20 +-
 internal/table/sql_query.go                   |  67 ++---
 internal/table/sql_query_test.go              |   7 +-
 internal/table/sql_table.go                   |  12 +-
 internal/table/static.go                      |   4 +-
 internal/target/queue/queue.go                |  34 +--
 internal/target/queue/queue_test.go           |  38 +--
 internal/target/remote/mxauth_test.go         |  28 +--
 internal/target/remote/policy_group.go        |   8 +-
 internal/target/remote/remote.go              |  21 +-
 internal/target/remote/remote_test.go         |  90 +++----
 internal/target/remote/security.go            |  79 +++---
 internal/target/smtp/smtp_downstream.go       |  28 +--
 internal/target/smtp/smtputf8_test.go         |   4 +-
 internal/testutils/bench_delivery.go          |   2 +-
 internal/testutils/check.go                   |   5 +-
 internal/testutils/modifier.go                |   5 +-
 internal/testutils/target.go                  |  16 +-
 internal/tls/acme/acme.go                     |  35 +--
 internal/tls/file.go                          |  45 ++--
 internal/tls/self_signed.go                   |   8 +-
 maddy.go                                      | 229 +++++++++++-------
 signal_nonposix.go                            |   2 +-
 103 files changed, 1305 insertions(+), 965 deletions(-)
 create mode 100644 framework/container/container.go
 delete mode 100644 framework/module/instances.go
 create mode 100644 framework/module/lifetime.go
 create mode 100644 framework/module/modules.go

diff --git a/framework/config/module/modconfig.go b/framework/config/module/modconfig.go
index 3183bb93..443bfebd 100644
--- a/framework/config/module/modconfig.go
+++ b/framework/config/module/modconfig.go
@@ -28,19 +28,18 @@ package modconfig
 
 import (
 	"fmt"
-	"io"
 	"reflect"
 	"strings"
 
 	parser "github.com/foxcpp/maddy/framework/cfgparser"
 	"github.com/foxcpp/maddy/framework/config"
-	"github.com/foxcpp/maddy/framework/hooks"
+	"github.com/foxcpp/maddy/framework/container"
 	"github.com/foxcpp/maddy/framework/log"
 	"github.com/foxcpp/maddy/framework/module"
 )
 
 // createInlineModule is a helper function for config matchers that can create inline modules.
-func createInlineModule(preferredNamespace, modName string, args []string) (module.Module, error) {
+func createInlineModule(preferredNamespace, modName string) (module.Module, error) {
 	var newMod module.FuncNewModule
 	originalModName := modName
 
@@ -61,26 +60,21 @@ func createInlineModule(preferredNamespace, modName string, args []string) (modu
 		return nil, fmt.Errorf("unknown module: %s (namespace: %s)", originalModName, preferredNamespace)
 	}
 
-	return newMod(modName, "", nil, args)
+	return newMod(modName, "")
 }
 
-// initInlineModule constructs "faked" config tree and passes it to module
+// configureInlineModule constructs "faked" config tree and passes it to module
 // Init function to make it look like it is defined at top-level.
 //
-// args must contain at least one argument, otherwise initInlineModule panics.
-func initInlineModule(modObj module.Module, globals map[string]interface{}, block config.Node) error {
-	err := modObj.Init(config.NewMap(globals, block))
+// args must contain at least one argument, otherwise configureInlineModule panics.
+func configureInlineModule(modObj module.Module, args []string, globals map[string]interface{}, block config.Node) error {
+	err := modObj.Configure(args, config.NewMap(globals, block))
 	if err != nil {
 		return err
 	}
 
-	if closer, ok := modObj.(io.Closer); ok {
-		hooks.AddHook(hooks.EventShutdown, func() {
-			log.Debugf("close %s (%s)", modObj.Name(), modObj.InstanceName())
-			if err := closer.Close(); err != nil {
-				log.Printf("module %s (%s) close failed: %v", modObj.Name(), modObj.InstanceName(), err)
-			}
-		})
+	if li, ok := modObj.(module.LifetimeModule); ok {
+		container.Global.Lifetime.Add(li)
 	}
 
 	return nil
@@ -117,11 +111,11 @@ func ModuleFromNode(preferredNamespace string, args []string, inlineCfg config.N
 		if len(args) != 1 || inlineCfg.Children != nil {
 			return parser.NodeErr(inlineCfg, "exactly one argument is required to use existing config block")
 		}
-		modObj, err = module.GetInstance(args[0][1:])
+		modObj, err = container.Global.Modules.Get(args[0][1:])
 		log.Debugf("%s:%d: reference %s", inlineCfg.File, inlineCfg.Line, args[0])
 	} else {
 		log.Debugf("%s:%d: new module %s %v", inlineCfg.File, inlineCfg.Line, args[0], args[1:])
-		modObj, err = createInlineModule(preferredNamespace, args[0], args[1:])
+		modObj, err = createInlineModule(preferredNamespace, args[0])
 	}
 	if err != nil {
 		return err
@@ -144,7 +138,7 @@ func ModuleFromNode(preferredNamespace string, args []string, inlineCfg config.N
 	reflect.ValueOf(moduleIface).Elem().Set(reflect.ValueOf(modObj))
 
 	if !referenceExisting {
-		if err := initInlineModule(modObj, globals, inlineCfg); err != nil {
+		if err := configureInlineModule(modObj, args[1:], globals, inlineCfg); err != nil {
 			return err
 		}
 	}
diff --git a/framework/container/container.go b/framework/container/container.go
new file mode 100644
index 00000000..dc1e0124
--- /dev/null
+++ b/framework/container/container.go
@@ -0,0 +1,70 @@
+/*
+Maddy Mail Server - Composable all-in-one email server.
+Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+package container
+
+import (
+	"github.com/foxcpp/maddy/framework/log"
+	"github.com/foxcpp/maddy/framework/module"
+)
+
+type GlobalConfig struct {
+	// StateDirectory contains the path to the directory that
+	// should be used to store any data that should be
+	// preserved between sessions.
+	//
+	// Value of this variable must not change after initialization
+	// in cmd/maddy/main.go.
+	StateDirectory string
+
+	// RuntimeDirectory contains the path to the directory that
+	// should be used to store any temporary data.
+	//
+	// It should be preferred over os.TempDir, which is
+	// global and world-readable on most systems, while
+	// RuntimeDirectory can be dedicated for maddy.
+	//
+	// Value of this variable must not change after initialization
+	// in cmd/maddy/main.go.
+	RuntimeDirectory string
+
+	// LibexecDirectory contains the path to the directory
+	// where helper binaries should be searched.
+	//
+	// Value of this variable must not change after initialization
+	// in cmd/maddy/main.go.
+	LibexecDirectory string
+}
+
+type C struct {
+	Config        GlobalConfig
+	DefaultLogger log.Logger
+	Modules       *module.Registry
+	Lifetime      *module.LifetimeTracker
+}
+
+func New() *C {
+	return &C{
+		DefaultLogger: log.DefaultLogger,
+		Modules:       module.NewRegistry(log.DefaultLogger.Sublogger("registry")),
+		Lifetime:      module.NewLifetime(log.DefaultLogger.Sublogger("lifetime")),
+	}
+}
+
+// Global is the default instance while refactoring is in progress.
+var Global *C
diff --git a/framework/log/log.go b/framework/log/log.go
index f9092a44..da8647f6 100644
--- a/framework/log/log.go
+++ b/framework/log/log.go
@@ -224,6 +224,17 @@ func (l Logger) log(debug bool, s string) {
 	// Logging is disabled - do nothing.
 }
 
+func (l Logger) Sublogger(name string) Logger {
+	if l.Name != "" {
+		name = l.Name + "/" + name
+	}
+	return Logger{
+		Out:   l.Out,
+		Name:  name,
+		Debug: l.Debug,
+	}
+}
+
 // DefaultLogger is the global Logger object that is used by
 // package-level logging functions.
 //
diff --git a/framework/module/delivery_target.go b/framework/module/delivery_target.go
index 9a7c1e09..40e5881a 100644
--- a/framework/module/delivery_target.go
+++ b/framework/module/delivery_target.go
@@ -33,12 +33,12 @@ import (
 // Modules implementing this interface should be registered with "target."
 // prefix in name.
 type DeliveryTarget interface {
-	// Start starts the delivery of a new message.
+	// StartDelivery starts the delivery of a new message.
 	//
 	// The domain part of the MAIL FROM address is assumed to be U-labels with
 	// NFC normalization and case-folding applied. The message source should
 	// ensure that by calling address.CleanDomain if necessary.
-	Start(ctx context.Context, msgMeta *MsgMetadata, mailFrom string) (Delivery, error)
+	StartDelivery(ctx context.Context, msgMeta *MsgMetadata, mailFrom string) (Delivery, error)
 }
 
 type Delivery interface {
@@ -54,7 +54,7 @@ type Delivery interface {
 	// however. They should be silently ignored.
 	//
 	// Implementation should do as much checks as possible here and reject
-	// recipients that can't be used.  Note: MsgMetadata object passed to Start
+	// recipients that can't be used.  Note: MsgMetadata object passed to StartDelivery
 	// contains BodyLength field. If it is non-zero, it can be used to check
 	// storage quota for the user before Body.
 	AddRcpt(ctx context.Context, rcptTo string, opts smtp.RcptOptions) error
diff --git a/framework/module/dummy.go b/framework/module/dummy.go
index 0930d4db..0722fb26 100644
--- a/framework/module/dummy.go
+++ b/framework/module/dummy.go
@@ -54,11 +54,11 @@ func (d *Dummy) InstanceName() string {
 	return d.instName
 }
 
-func (d *Dummy) Init(_ *config.Map) error {
+func (d *Dummy) Configure(_ []string, _ *config.Map) error {
 	return nil
 }
 
-func (d *Dummy) Start(ctx context.Context, msgMeta *MsgMetadata, mailFrom string) (Delivery, error) {
+func (d *Dummy) StartDelivery(ctx context.Context, msgMeta *MsgMetadata, mailFrom string) (Delivery, error) {
 	return dummyDelivery{}, nil
 }
 
@@ -81,7 +81,7 @@ func (dd dummyDelivery) Commit(ctx context.Context) error {
 }
 
 func init() {
-	Register("dummy", func(_, instName string, _, _ []string) (Module, error) {
+	Register("dummy", func(_, instName string) (Module, error) {
 		return &Dummy{instName: instName}, nil
 	})
 }
diff --git a/framework/module/instances.go b/framework/module/instances.go
deleted file mode 100644
index aa6f1489..00000000
--- a/framework/module/instances.go
+++ /dev/null
@@ -1,105 +0,0 @@
-/*
-Maddy Mail Server - Composable all-in-one email server.
-Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors
-
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU General Public License as published by
-the Free Software Foundation, either version 3 of the License, or
-(at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU General Public License for more details.
-
-You should have received a copy of the GNU General Public License
-along with this program.  If not, see <https://www.gnu.org/licenses/>.
-*/
-
-package module
-
-import (
-	"fmt"
-	"io"
-
-	"github.com/foxcpp/maddy/framework/config"
-	"github.com/foxcpp/maddy/framework/hooks"
-	"github.com/foxcpp/maddy/framework/log"
-)
-
-var (
-	instances = make(map[string]struct {
-		mod Module
-		cfg *config.Map
-	})
-	aliases = make(map[string]string)
-
-	Initialized = make(map[string]bool)
-)
-
-// RegisterInstance adds module instance to the global registry.
-//
-// Instance name must be unique. Second RegisterInstance with same instance
-// name will replace previous.
-func RegisterInstance(inst Module, cfg *config.Map) {
-	instances[inst.InstanceName()] = struct {
-		mod Module
-		cfg *config.Map
-	}{inst, cfg}
-}
-
-// RegisterAlias creates an association between a certain name and instance name.
-//
-// After RegisterAlias, module.GetInstance(aliasName) will return the same
-// result as module.GetInstance(instName).
-func RegisterAlias(aliasName, instName string) {
-	aliases[aliasName] = instName
-}
-
-func HasInstance(name string) bool {
-	aliasedName := aliases[name]
-	if aliasedName != "" {
-		name = aliasedName
-	}
-
-	_, ok := instances[name]
-	return ok
-}
-
-// GetInstance returns module instance from global registry, initializing it if
-// necessary.
-//
-// Error is returned if module initialization fails or module instance does not
-// exists.
-func GetInstance(name string) (Module, error) {
-	aliasedName := aliases[name]
-	if aliasedName != "" {
-		name = aliasedName
-	}
-
-	mod, ok := instances[name]
-	if !ok {
-		return nil, fmt.Errorf("unknown config block: %s", name)
-	}
-
-	// Break circular dependencies.
-	if Initialized[name] {
-		return mod.mod, nil
-	}
-
-	Initialized[name] = true
-	if err := mod.mod.Init(mod.cfg); err != nil {
-		return mod.mod, err
-	}
-
-	if closer, ok := mod.mod.(io.Closer); ok {
-		hooks.AddHook(hooks.EventShutdown, func() {
-			log.Debugf("close %s (%s)", mod.mod.Name(), mod.mod.InstanceName())
-			if err := closer.Close(); err != nil {
-				log.Printf("module %s (%s) close failed: %v", mod.mod.Name(), mod.mod.InstanceName(), err)
-			}
-		})
-	}
-
-	return mod.mod, nil
-}
diff --git a/framework/module/lifetime.go b/framework/module/lifetime.go
new file mode 100644
index 00000000..1b0339da
--- /dev/null
+++ b/framework/module/lifetime.go
@@ -0,0 +1,121 @@
+/*
+Maddy Mail Server - Composable all-in-one email server.
+Copyright © 2019-2025 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+package module
+
+import (
+	"fmt"
+
+	"github.com/foxcpp/maddy/framework/log"
+)
+
+// LifetimeModule is a stateful module that needs to have post-configuration
+// startup and graceful shutdown functionality.
+type LifetimeModule interface {
+	Module
+	Start() error
+	Stop() error
+}
+
+type ReloadModule interface {
+	Module
+	Reload() error
+}
+
+type LifetimeTracker struct {
+	logger    log.Logger
+	instances []*struct {
+		mod     LifetimeModule
+		started bool
+	}
+}
+
+func (lt *LifetimeTracker) Add(mod LifetimeModule) {
+	lt.instances = append(lt.instances, &struct {
+		mod     LifetimeModule
+		started bool
+	}{mod: mod, started: false})
+}
+
+// StartAll calls Start for all registered LifetimeModule instances.
+func (lt *LifetimeTracker) StartAll() error {
+	for _, entry := range lt.instances {
+		if entry.started {
+			continue
+		}
+
+		if err := entry.mod.Start(); err != nil {
+			lt.StopAll()
+			return fmt.Errorf("failed to start module %v: %w",
+				entry.mod.InstanceName(), err)
+		}
+		lt.logger.DebugMsg("module started",
+			"mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName())
+		entry.started = true
+	}
+	return nil
+}
+
+func (lt *LifetimeTracker) ReloadAll() error {
+	for _, entry := range lt.instances {
+		if !entry.started {
+			continue
+		}
+
+		rm, ok := entry.mod.(ReloadModule)
+		if !ok {
+			continue
+		}
+
+		if err := rm.Reload(); err != nil {
+			lt.logger.Error("module reload failed", err,
+				"mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName())
+			continue
+		}
+
+		lt.logger.DebugMsg("module reloaded",
+			"mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName())
+	}
+	return nil
+}
+
+// StopAll calls Stop for all registered LifetimeModule instances.
+func (lt *LifetimeTracker) StopAll() error {
+	for _, entry := range lt.instances {
+		if !entry.started {
+			continue
+		}
+
+		if err := entry.mod.Stop(); err != nil {
+			lt.logger.Error("module stop failed", err,
+				"mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName())
+			continue
+		}
+		lt.logger.DebugMsg("module stopped",
+			"mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName())
+
+		entry.started = false
+	}
+	return nil
+}
+
+func NewLifetime(log log.Logger) *LifetimeTracker {
+	return &LifetimeTracker{
+		logger: log,
+	}
+}
diff --git a/framework/module/module.go b/framework/module/module.go
index 2dbb45e9..2f949c14 100644
--- a/framework/module/module.go
+++ b/framework/module/module.go
@@ -38,22 +38,8 @@ import (
 )
 
 // Module is the interface implemented by all maddy module instances.
-//
-// It defines basic methods used to identify instances.
-//
-// Additionally, module can implement io.Closer if it needs to perform clean-up
-// on shutdown. If module starts long-lived goroutines - they should be stopped
-// *before* Close method returns to ensure graceful shutdown.
 type Module interface {
-	// Init performs actual initialization of the module.
-	//
-	// It is not done in FuncNewModule so all module instances are
-	// registered at time of initialization, thus initialization does not
-	// depends on ordering of configuration blocks and modules can reference
-	// each other without any problems.
-	//
-	// Module can use passed config.Map to read its configuration variables.
-	Init(*config.Map) error
+	Configure(inlineArgs []string, config *config.Map) error
 
 	// Name method reports module name.
 	//
@@ -68,12 +54,10 @@ type Module interface {
 // FuncNewModule is function that creates new instance of module with specified name.
 //
 // Module.InstanceName() of the returned module object should return instName.
-// aliases slice contains other names that can be used to reference created
-// module instance.
+// If module is defined inline, instName will be empty.
 //
-// If module is defined inline, instName will be empty and all values
-// specified after module name in configuration will be in inlineArgs.
-type FuncNewModule func(modName, instName string, aliases, inlineArgs []string) (Module, error)
+// Returned Module may additionally implement LifetimeModule.
+type FuncNewModule func(modName, instName string) (Module, error)
 
 // FuncNewEndpoint is a function that creates new instance of endpoint
 // module.
@@ -87,4 +71,4 @@ type FuncNewModule func(modName, instName string, aliases, inlineArgs []string)
 //
 // As a consequence of having no per-instance name, InstanceName of the module
 // object always returns the same value as Name.
-type FuncNewEndpoint func(modName string, addrs []string) (Module, error)
+type FuncNewEndpoint func(modName string, addrs []string) (LifetimeModule, error)
diff --git a/framework/module/modules.go b/framework/module/modules.go
new file mode 100644
index 00000000..d7669808
--- /dev/null
+++ b/framework/module/modules.go
@@ -0,0 +1,96 @@
+/*
+Maddy Mail Server - Composable all-in-one email server.
+Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program.  If not, see <https://www.gnu.org/licenses/>.
+*/
+
+package module
+
+import (
+	"sync"
+
+	"github.com/foxcpp/maddy/framework/log"
+)
+
+var (
+	modules     = make(map[string]FuncNewModule)
+	endpoints   = make(map[string]FuncNewEndpoint)
+	modulesLock sync.RWMutex
+)
+
+// Register adds module factory function to global registry.
+//
+// name must be unique. Register will panic if module with specified name
+// already exists in registry.
+//
+// You probably want to call this function from func init() of module package.
+func Register(name string, factory FuncNewModule) {
+	modulesLock.Lock()
+	defer modulesLock.Unlock()
+
+	if _, ok := modules[name]; ok {
+		panic("Register: module with specified name is already registered: " + name)
+	}
+
+	modules[name] = factory
+}
+
+// RegisterDeprecated adds module factory function to global registry.
+//
+// It prints warning to the log about name being deprecated and suggests using
+// a new name.
+func RegisterDeprecated(name, newName string, factory FuncNewModule) {
+	Register(name, func(modName, instName string) (Module, error) {
+		log.Printf("module initialized via deprecated name %s, %s should be used instead; deprecated name may be removed in the next version", name, newName)
+		return factory(modName, instName)
+	})
+}
+
+// Get returns module from global registry.
+//
+// This function does not return endpoint-type modules, use GetEndpoint for
+// that.
+// Nil is returned if no module with specified name is registered.
+func Get(name string) FuncNewModule {
+	modulesLock.RLock()
+	defer modulesLock.RUnlock()
+
+	return modules[name]
+}
+
+// GetEndpoint returns an endpoint module from global registry.
+//
+// Nil is returned if no module with specified name is registered.
+func GetEndpoint(name string) FuncNewEndpoint {
+	modulesLock.RLock()
+	defer modulesLock.RUnlock()
+
+	return endpoints[name]
+}
+
+// RegisterEndpoint registers an endpoint module.
+//
+// See FuncNewEndpoint for information about
+// differences of endpoint modules from regular modules.
+func RegisterEndpoint(name string, factory FuncNewEndpoint) {
+	modulesLock.Lock()
+	defer modulesLock.Unlock()
+
+	if _, ok := endpoints[name]; ok {
+		panic("Register: module with specified name is already registered: " + name)
+	}
+
+	endpoints[name] = factory
+}
diff --git a/framework/module/mxauth.go b/framework/module/mxauth.go
index 5226fb02..ac2167ca 100644
--- a/framework/module/mxauth.go
+++ b/framework/module/mxauth.go
@@ -96,7 +96,7 @@ type (
 	// Modules implementing this interface should be registered with "mx_auth."
 	// prefix in name.
 	MXAuthPolicy interface {
-		Start(*MsgMetadata) DeliveryMXAuthPolicy
+		StartDelivery(*MsgMetadata) DeliveryMXAuthPolicy
 
 		// Weight is an integer in range 0-1000 that represents relative
 		// ordering of policy application.
diff --git a/framework/module/partial_delivery.go b/framework/module/partial_delivery.go
index beeb46e8..fa1f7471 100644
--- a/framework/module/partial_delivery.go
+++ b/framework/module/partial_delivery.go
@@ -45,7 +45,7 @@ type StatusCollector interface {
 }
 
 // PartialDelivery is an optional interface that may be implemented
-// by the object returned by DeliveryTarget.Start. See PartialDelivery.BodyNonAtomic
+// by the object returned by DeliveryTarget.StartDelivery. See PartialDelivery.BodyNonAtomic
 // documentation for details.
 type PartialDelivery interface {
 	// BodyNonAtomic is similar to Body method of the regular Delivery interface
diff --git a/framework/module/registry.go b/framework/module/registry.go
index c52210f8..ec62edf7 100644
--- a/framework/module/registry.go
+++ b/framework/module/registry.go
@@ -19,85 +19,129 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
 package module
 
 import (
-	"sync"
+	"errors"
 
 	"github.com/foxcpp/maddy/framework/log"
 )
 
 var (
-	// NoRun makes sure modules do not start any bacground tests.
-	//
-	// If it set - modules should not perform any actual work and should stop
-	// once the configuration is read and verified to be correct.
-	// TODO: Replace it with separation of Init and Run at interface level.
-	NoRun = false
-
-	modules     = make(map[string]FuncNewModule)
-	endpoints   = make(map[string]FuncNewEndpoint)
-	modulesLock sync.RWMutex
+	ErrInstanceNameDuplicate = errors.New("instance name already registered")
+	ErrInstanceUnknown       = errors.New("no such instance registered")
 )
 
-// Register adds module factory function to global registry.
-//
-// name must be unique. Register will panic if module with specified name
-// already exists in registry.
-//
-// You probably want to call this function from func init() of module package.
-func Register(name string, factory FuncNewModule) {
-	modulesLock.Lock()
-	defer modulesLock.Unlock()
+type registryEntry struct {
+	Mod      Module
+	LazyInit func() error
+}
 
-	if _, ok := modules[name]; ok {
-		panic("Register: module with specified name is already registered: " + name)
-	}
+type Registry struct {
+	logger      log.Logger
+	instances   map[string]registryEntry
+	initialized map[string]struct{}
+	started     map[string]struct{}
+	aliases     map[string]string
+}
 
-	modules[name] = factory
+func NewRegistry(log log.Logger) *Registry {
+	return &Registry{
+		logger:      log,
+		instances:   make(map[string]registryEntry),
+		initialized: make(map[string]struct{}),
+		started:     make(map[string]struct{}),
+		aliases:     make(map[string]string),
+	}
 }
 
-// RegisterDeprecated adds module factory function to global registry.
+// Register adds not-initialized (configured) module into registry.
 //
-// It prints warning to the log about name being deprecated and suggests using
-// a new name.
-func RegisterDeprecated(name, newName string, factory FuncNewModule) {
-	Register(name, func(modName, instName string, aliases, inlineArgs []string) (Module, error) {
-		log.Printf("module initialized via deprecated name %s, %s should be used instead; deprecated name may be removed in the next version", name, newName)
-		return factory(modName, instName, aliases, inlineArgs)
-	})
+// lazyInit function will be called on first request to get the module from
+// registry.
+func (r *Registry) Register(mod Module, lazyInit func() error) error {
+	instName := mod.InstanceName()
+	if instName == "" {
+		panic("module with empty instance name cannot be added to the registry")
+	}
+
+	_, ok := r.instances[instName]
+	if ok {
+		return ErrInstanceNameDuplicate
+	}
+
+	r.instances[instName] = registryEntry{
+		Mod:      mod,
+		LazyInit: lazyInit,
+	}
+	return nil
 }
 
-// Get returns module from global registry.
-//
-// This function does not return endpoint-type modules, use GetEndpoint for
-// that.
-// Nil is returned if no module with specified name is registered.
-func Get(name string) FuncNewModule {
-	modulesLock.RLock()
-	defer modulesLock.RUnlock()
-
-	return modules[name]
+func (r *Registry) AddAlias(instanceName string, alias string) error {
+	if instanceName == "" {
+		panic("cannot add an alias for empty instance name")
+	}
+	if alias == "" {
+		panic("cannot add an empty alias")
+	}
+	_, ok := r.aliases[alias]
+	if ok {
+		return ErrInstanceNameDuplicate
+	}
+	_, ok = r.instances[instanceName]
+	if ok {
+		return ErrInstanceNameDuplicate
+	}
+
+	r.aliases[alias] = instanceName
+	return nil
 }
 
-// GetEndpoints returns an endpoint module from global registry.
-//
-// Nil is returned if no module with specified name is registered.
-func GetEndpoint(name string) FuncNewEndpoint {
-	modulesLock.RLock()
-	defer modulesLock.RUnlock()
+func (r *Registry) ensureInitialized(name string, entry *registryEntry) error {
+	_, ok := r.initialized[name]
+	if ok {
+		return nil
+	}
+	if entry.LazyInit == nil {
+		return nil
+	}
+
+	r.logger.DebugMsg("module configure",
+		"mod_name", entry.Mod.Name(), "inst_name", entry.Mod.InstanceName())
+	err := entry.LazyInit()
+	if err != nil {
+		return err
+	}
+	r.initialized[name] = struct{}{}
 
-	return endpoints[name]
+	return nil
 }
 
-// RegisterEndpoint registers an endpoint module.
-//
-// See FuncNewEndpoint for information about
-// differences of endpoint modules from regular modules.
-func RegisterEndpoint(name string, factory FuncNewEndpoint) {
-	modulesLock.Lock()
-	defer modulesLock.Unlock()
+func (r *Registry) Get(name string) (Module, error) {
+	if name == "" {
+		panic("cannot get module with empty name")
+	}
+	aliasedName := r.aliases[name]
+	if aliasedName != "" {
+		name = aliasedName
+	}
+
+	mod, ok := r.instances[name]
+	if !ok {
+		return nil, ErrInstanceUnknown
+	}
 
-	if _, ok := endpoints[name]; ok {
-		panic("Register: module with specified name is already registered: " + name)
+	if err := r.ensureInitialized(name, &mod); err != nil {
+		return nil, err
 	}
 
-	endpoints[name] = factory
+	return mod.Mod, nil
+}
+
+func (r *Registry) NotInitialized() []Module {
+	notinit := make([]Module, 0, len(r.instances)-len(r.initialized))
+	for name, mod := range r.instances {
+		if _, ok := r.initialized[name]; ok {
+			continue
+		}
+		notinit = append(notinit, mod.Mod)
+	}
+	return notinit
 }
diff --git a/internal/auth/dovecot_sasl/dovecot_sasl.go b/internal/auth/dovecot_sasl/dovecot_sasl.go
index c7dd6cc9..cc5dd370 100644
--- a/internal/auth/dovecot_sasl/dovecot_sasl.go
+++ b/internal/auth/dovecot_sasl/dovecot_sasl.go
@@ -44,20 +44,12 @@ type Auth struct {
 
 const modName = "dovecot_sasl"
 
-func New(_, instName string, _, inlineArgs []string) (module.Module, error) {
+func New(_, instName string) (module.Module, error) {
 	a := &Auth{
 		instName: instName,
 		log:      log.Logger{Name: modName, Debug: log.DefaultLogger.Debug},
 	}
 
-	switch len(inlineArgs) {
-	case 0:
-	case 1:
-		a.serverEndpoint = inlineArgs[0]
-	default:
-		return nil, fmt.Errorf("%s: one or none arguments needed", modName)
-	}
-
 	return a, nil
 }
 
@@ -88,7 +80,15 @@ func (a *Auth) returnConn(cl *dovecotsasl.Client) {
 	cl.Close()
 }
 
-func (a *Auth) Init(cfg *config.Map) error {
+func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error {
+	switch len(inlineArgs) {
+	case 0:
+	case 1:
+		a.serverEndpoint = inlineArgs[0]
+	default:
+		return fmt.Errorf("%s: one or none arguments needed", modName)
+	}
+
 	cfg.String("endpoint", false, false, a.serverEndpoint, &a.serverEndpoint)
 	if _, err := cfg.Process(); err != nil {
 		return err
diff --git a/internal/auth/external/externalauth.go b/internal/auth/external/externalauth.go
index 59d71fb3..144864da 100644
--- a/internal/auth/external/externalauth.go
+++ b/internal/auth/external/externalauth.go
@@ -41,17 +41,13 @@ type ExternalAuth struct {
 	Log log.Logger
 }
 
-func NewExternalAuth(modName, instName string, _, inlineArgs []string) (module.Module, error) {
+func NewExternalAuth(modName, instName string) (module.Module, error) {
 	ea := &ExternalAuth{
 		modName:  modName,
 		instName: instName,
 		Log:      log.Logger{Name: modName},
 	}
 
-	if len(inlineArgs) != 0 {
-		return nil, errors.New("external: inline arguments are not used")
-	}
-
 	return ea, nil
 }
 
@@ -63,7 +59,11 @@ func (ea *ExternalAuth) InstanceName() string {
 	return ea.instName
 }
 
-func (ea *ExternalAuth) Init(cfg *config.Map) error {
+func (ea *ExternalAuth) Configure(inlineArgs []string, cfg *config.Map) error {
+	if len(inlineArgs) != 0 {
+		return errors.New("external: inline arguments are not used")
+	}
+
 	cfg.Bool("debug", false, false, &ea.Log.Debug)
 	cfg.Bool("perdomain", false, false, &ea.perDomain)
 	cfg.StringList("domains", false, false, nil, &ea.domains)
diff --git a/internal/auth/ldap/ldap.go b/internal/auth/ldap/ldap.go
index 04cfe9f9..a2392d56 100644
--- a/internal/auth/ldap/ldap.go
+++ b/internal/auth/ldap/ldap.go
@@ -40,15 +40,16 @@ type Auth struct {
 	log log.Logger
 }
 
-func New(modName, instName string, _, inlineArgs []string) (module.Module, error) {
+func New(modName, instName string) (module.Module, error) {
 	return &Auth{
 		instName: instName,
 		log:      log.Logger{Name: modName},
-		urls:     inlineArgs,
 	}, nil
 }
 
-func (a *Auth) Init(cfg *config.Map) error {
+func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error {
+	a.urls = inlineArgs
+
 	a.dialer = &net.Dialer{}
 
 	cfg.Bool("debug", true, false, &a.log.Debug)
@@ -87,15 +88,6 @@ func (a *Auth) Init(cfg *config.Map) error {
 		}
 	}
 
-	if module.NoRun {
-		return nil
-	}
-
-	var err error
-	a.conn, err = a.newConn()
-	if err != nil {
-		return fmt.Errorf("auth.ldap: %w", err)
-	}
 	return nil
 }
 
@@ -281,6 +273,22 @@ func (a *Auth) AuthPlain(username, password string) error {
 	return nil
 }
 
+func (a *Auth) Start() error {
+	var err error
+	a.conn, err = a.newConn()
+	if err != nil {
+		return fmt.Errorf("auth.ldap: %w", err)
+	}
+	return nil
+}
+
+func (a *Auth) Stop() error {
+	a.connLock.Lock()
+	defer a.connLock.Unlock()
+	a.conn.Close()
+	return nil
+}
+
 func init() {
 	var _ module.PlainAuth = &Auth{}
 	var _ module.Table = &Auth{}
diff --git a/internal/auth/netauth/netauth.go b/internal/auth/netauth/netauth.go
index 3348eefe..62d6c6cc 100644
--- a/internal/auth/netauth/netauth.go
+++ b/internal/auth/netauth/netauth.go
@@ -31,15 +31,17 @@ type Auth struct {
 }
 
 // New creates a new instance of the NetAuth module.
-func New(modName, instName string, _, inlineArgs []string) (module.Module, error) {
+func New(modName, instName string) (module.Module, error) {
 	return &Auth{
 		instName: instName,
 		log:      log.Logger{Name: modName},
 	}, nil
 }
+func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error {
+	if len(inlineArgs) > 0 {
+		return fmt.Errorf("%s: inline arguments are not used", modName)
+	}
 
-// Init performs deferred initialization actions.
-func (a *Auth) Init(cfg *config.Map) error {
 	l := hclog.New(&hclog.LoggerOptions{Output: a.log})
 	n, err := netauth.NewWithLog(l)
 	if err != nil {
@@ -53,8 +55,6 @@ func (a *Auth) Init(cfg *config.Map) error {
 		return err
 	}
 
-	a.log.Debugln("Debug logging enabled")
-	a.log.Debugf("mustGroups status: %s", a.mustGroup)
 	return nil
 }
 
diff --git a/internal/auth/pam/module.go b/internal/auth/pam/module.go
index c93269d3..da8e59f9 100644
--- a/internal/auth/pam/module.go
+++ b/internal/auth/pam/module.go
@@ -38,10 +38,7 @@ type Auth struct {
 	Log log.Logger
 }
 
-func New(modName, instName string, _, inlineArgs []string) (module.Module, error) {
-	if len(inlineArgs) != 0 {
-		return nil, errors.New("pam: inline arguments are not used")
-	}
+func New(modName, instName string) (module.Module, error) {
 	return &Auth{
 		instName: instName,
 		Log:      log.Logger{Name: modName},
@@ -56,7 +53,11 @@ func (a *Auth) InstanceName() string {
 	return a.instName
 }
 
-func (a *Auth) Init(cfg *config.Map) error {
+func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error {
+	if len(inlineArgs) != 0 {
+		return errors.New("pam: inline arguments are not used")
+	}
+
 	cfg.Bool("debug", true, false, &a.Log.Debug)
 	cfg.Bool("use_helper", false, false, &a.useHelper)
 	if _, err := cfg.Process(); err != nil {
diff --git a/internal/auth/pass_table/table.go b/internal/auth/pass_table/table.go
index 4d0e3137..626913b4 100644
--- a/internal/auth/pass_table/table.go
+++ b/internal/auth/pass_table/table.go
@@ -31,24 +31,22 @@ import (
 )
 
 type Auth struct {
-	modName    string
-	instName   string
-	inlineArgs []string
+	modName  string
+	instName string
 
 	table module.Table
 }
 
-func New(modName, instName string, _, inlineArgs []string) (module.Module, error) {
+func New(modName, instName string) (module.Module, error) {
 	return &Auth{
-		modName:    modName,
-		instName:   instName,
-		inlineArgs: inlineArgs,
+		modName:  modName,
+		instName: instName,
 	}, nil
 }
 
-func (a *Auth) Init(cfg *config.Map) error {
-	if len(a.inlineArgs) != 0 {
-		return modconfig.ModuleFromNode("table", a.inlineArgs, cfg.Block, cfg.Globals, &a.table)
+func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error {
+	if len(inlineArgs) != 0 {
+		return modconfig.ModuleFromNode("table", inlineArgs, cfg.Block, cfg.Globals, &a.table)
 	}
 
 	cfg.Custom("table", false, true, nil, modconfig.TableDirective, &a.table)
diff --git a/internal/auth/pass_table/table_test.go b/internal/auth/pass_table/table_test.go
index 666e2b60..7f688cac 100644
--- a/internal/auth/pass_table/table_test.go
+++ b/internal/auth/pass_table/table_test.go
@@ -28,11 +28,11 @@ import (
 func TestAuth_AuthPlain(t *testing.T) {
 	addSHA256()
 
-	mod, err := New("pass_table", "", nil, []string{"dummy"})
+	mod, err := New("pass_table", "")
 	if err != nil {
 		t.Fatal(err)
 	}
-	err = mod.Init(config.NewMap(nil, config.Node{
+	err = mod.Configure([]string{"dummy"}, config.NewMap(nil, config.Node{
 		Children: []config.Node{},
 	}))
 	if err != nil {
diff --git a/internal/auth/plain_separate/plain_separate.go b/internal/auth/plain_separate/plain_separate.go
index 893d1f01..b3e06016 100644
--- a/internal/auth/plain_separate/plain_separate.go
+++ b/internal/auth/plain_separate/plain_separate.go
@@ -41,7 +41,7 @@ type Auth struct {
 	Log log.Logger
 }
 
-func NewAuth(modName, instName string, _, inlinargs []string) (module.Module, error) {
+func NewAuth(modName, instName string) (module.Module, error) {
 	a := &Auth{
 		modName:     modName,
 		instName:    instName,
@@ -49,10 +49,6 @@ func NewAuth(modName, instName string, _, inlinargs []string) (module.Module, er
 		Log:         log.Logger{Name: modName},
 	}
 
-	if len(inlinargs) != 0 {
-		return nil, errors.New("plain_separate: inline arguments are not used")
-	}
-
 	return a, nil
 }
 
@@ -64,7 +60,11 @@ func (a *Auth) InstanceName() string {
 	return a.instName
 }
 
-func (a *Auth) Init(cfg *config.Map) error {
+func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error {
+	if len(inlineArgs) != 0 {
+		return errors.New("plain_separate: inline arguments are not used")
+	}
+
 	cfg.Bool("debug", false, false, &a.Log.Debug)
 	cfg.Callback("user", func(m *config.Map, node config.Node) error {
 		var tbl module.Table
diff --git a/internal/auth/shadow/module.go b/internal/auth/shadow/module.go
index 92307bfc..290d826e 100644
--- a/internal/auth/shadow/module.go
+++ b/internal/auth/shadow/module.go
@@ -41,10 +41,7 @@ type Auth struct {
 	Log log.Logger
 }
 
-func New(modName, instName string, _, inlineArgs []string) (module.Module, error) {
-	if len(inlineArgs) != 0 {
-		return nil, errors.New("shadow: inline arguments are not used")
-	}
+func New(modName, instName string) (module.Module, error) {
 	return &Auth{
 		instName: instName,
 		Log:      log.Logger{Name: modName},
@@ -59,7 +56,11 @@ func (a *Auth) InstanceName() string {
 	return a.instName
 }
 
-func (a *Auth) Init(cfg *config.Map) error {
+func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error {
+	if len(inlineArgs) != 0 {
+		return errors.New("shadow: inline arguments are not used")
+	}
+
 	cfg.Bool("debug", true, false, &a.Log.Debug)
 	cfg.Bool("use_helper", false, false, &a.useHelper)
 	if _, err := cfg.Process(); err != nil {
diff --git a/internal/check/authorize_sender/authorize_sender.go b/internal/check/authorize_sender/authorize_sender.go
index 6827add2..ab091c56 100644
--- a/internal/check/authorize_sender/authorize_sender.go
+++ b/internal/check/authorize_sender/authorize_sender.go
@@ -20,6 +20,7 @@ package authorize_sender
 
 import (
 	"context"
+	"fmt"
 	"net/mail"
 
 	"github.com/emersion/go-message/textproto"
@@ -52,7 +53,7 @@ type Check struct {
 	authNorm authz.NormalizeFunc
 }
 
-func New(_, instName string, _, inlineArgs []string) (module.Module, error) {
+func New(_, instName string) (module.Module, error) {
 	return &Check{
 		instName: instName,
 	}, nil
@@ -66,7 +67,11 @@ func (c *Check) InstanceName() string {
 	return c.instName
 }
 
-func (c *Check) Init(cfg *config.Map) error {
+func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error {
+	if len(inlineArgs) != 0 {
+		return fmt.Errorf("%s: inline arguments are not used", modName)
+	}
+
 	cfg.Bool("debug", true, false, &c.log.Debug)
 
 	cfg.Bool("check_header", false, true, &c.checkHeader)
diff --git a/internal/check/command/command.go b/internal/check/command/command.go
index 60937607..512f147a 100644
--- a/internal/check/command/command.go
+++ b/internal/check/command/command.go
@@ -66,7 +66,7 @@ type Check struct {
 	cmdArgs []string
 }
 
-func New(modName, instName string, aliases, inlineArgs []string) (module.Module, error) {
+func New(modName, instName string) (module.Module, error) {
 	c := &Check{
 		instName: instName,
 		actions: map[int]modconfig.FailAction{
@@ -79,13 +79,6 @@ func New(modName, instName string, aliases, inlineArgs []string) (module.Module,
 		},
 	}
 
-	if len(inlineArgs) == 0 {
-		return nil, errors.New("command: at least one argument is required (command name)")
-	}
-
-	c.cmd = inlineArgs[0]
-	c.cmdArgs = inlineArgs[1:]
-
 	return c, nil
 }
 
@@ -97,7 +90,14 @@ func (c *Check) InstanceName() string {
 	return c.instName
 }
 
-func (c *Check) Init(cfg *config.Map) error {
+func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error {
+	if len(inlineArgs) == 0 {
+		return errors.New("command: at least one argument is required (command name)")
+	}
+
+	c.cmd = inlineArgs[0]
+	c.cmdArgs = inlineArgs[1:]
+
 	// Check whether the inline argument command is usable.
 	if _, err := exec.LookPath(c.cmd); err != nil {
 		return fmt.Errorf("command: %w", err)
diff --git a/internal/check/dkim/dkim.go b/internal/check/dkim/dkim.go
index 563fc5b1..b6921d40 100644
--- a/internal/check/dkim/dkim.go
+++ b/internal/check/dkim/dkim.go
@@ -52,10 +52,7 @@ type Check struct {
 	resolver dns.Resolver
 }
 
-func New(_, instName string, _, inlineArgs []string) (module.Module, error) {
-	if len(inlineArgs) != 0 {
-		return nil, errors.New("check.dkim: inline arguments are not used")
-	}
+func New(_, instName string) (module.Module, error) {
 	return &Check{
 		instName: instName,
 		log:      log.Logger{Name: "check.dkim"},
@@ -63,7 +60,11 @@ func New(_, instName string, _, inlineArgs []string) (module.Module, error) {
 	}, nil
 }
 
-func (c *Check) Init(cfg *config.Map) error {
+func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error {
+	if len(inlineArgs) != 0 {
+		return errors.New("check.dkim: inline arguments are not used")
+	}
+
 	var requiredFields []string
 
 	cfg.Bool("debug", true, false, &c.log.Debug)
diff --git a/internal/check/dkim/dkim_test.go b/internal/check/dkim/dkim_test.go
index 020d50f5..b4054124 100644
--- a/internal/check/dkim/dkim_test.go
+++ b/internal/check/dkim/dkim_test.go
@@ -84,7 +84,7 @@ Joe.
 
 func testCheck(t *testing.T, zones map[string]mockdns.Zone, cfg []config.Node) *Check {
 	t.Helper()
-	mod, err := New("check.dkim", "", nil, nil)
+	mod, err := New("check.dkim", "")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -92,7 +92,7 @@ func testCheck(t *testing.T, zones map[string]mockdns.Zone, cfg []config.Node) *
 	check.resolver = &mockdns.Resolver{Zones: zones}
 	check.log = testutils.Logger(t, mod.Name())
 
-	if err := check.Init(config.NewMap(nil, config.Node{Children: cfg})); err != nil {
+	if err := check.Configure(nil, config.NewMap(nil, config.Node{Children: cfg})); err != nil {
 		t.Fatal(err)
 	}
 
diff --git a/internal/check/dnsbl/dnsbl.go b/internal/check/dnsbl/dnsbl.go
index 2c91c838..79738720 100644
--- a/internal/check/dnsbl/dnsbl.go
+++ b/internal/check/dnsbl/dnsbl.go
@@ -58,7 +58,6 @@ var defaultBL = List{
 type DNSBL struct {
 	instName   string
 	checkEarly bool
-	inlineBls  []string
 	bls        []List
 
 	quarantineThres int
@@ -68,10 +67,9 @@ type DNSBL struct {
 	log      log.Logger
 }
 
-func NewDNSBL(_, instName string, _, inlineArgs []string) (module.Module, error) {
+func New(_, instName string) (module.Module, error) {
 	return &DNSBL{
-		instName:  instName,
-		inlineBls: inlineArgs,
+		instName: instName,
 
 		resolver: dns.DefaultResolver(),
 		log:      log.Logger{Name: "dnsbl"},
@@ -86,7 +84,7 @@ func (bl *DNSBL) InstanceName() string {
 	return bl.instName
 }
 
-func (bl *DNSBL) Init(cfg *config.Map) error {
+func (bl *DNSBL) Configure(inlineArgs []string, cfg *config.Map) error {
 	cfg.Bool("debug", false, false, &bl.log.Debug)
 	cfg.Bool("check_early", false, false, &bl.checkEarly)
 	cfg.Int("quarantine_threshold", false, false, 1, &bl.quarantineThres)
@@ -97,7 +95,7 @@ func (bl *DNSBL) Init(cfg *config.Map) error {
 		return err
 	}
 
-	for _, inlineBl := range bl.inlineBls {
+	for _, inlineBl := range inlineArgs {
 		cfg := defaultBL
 		cfg.Zone = inlineBl
 		go bl.testList(cfg)
@@ -431,5 +429,5 @@ func (*state) Close() error {
 }
 
 func init() {
-	module.Register("check.dnsbl", NewDNSBL)
+	module.Register("check.dnsbl", New)
 }
diff --git a/internal/check/milter/milter.go b/internal/check/milter/milter.go
index 37704d43..38f81930 100644
--- a/internal/check/milter/milter.go
+++ b/internal/check/milter/milter.go
@@ -46,18 +46,12 @@ type Check struct {
 	log       log.Logger
 }
 
-func New(_, instName string, _, inlineArgs []string) (module.Module, error) {
+func New(_, instName string) (module.Module, error) {
 	c := &Check{
 		instName: instName,
 		log:      log.Logger{Name: modName, Debug: log.DefaultLogger.Debug},
 	}
-	switch len(inlineArgs) {
-	case 1:
-		c.milterUrl = inlineArgs[0]
-	case 0:
-	default:
-		return nil, fmt.Errorf("%s: unexpected amount of arguments, want 1 or 0", modName)
-	}
+
 	return c, nil
 }
 
@@ -69,7 +63,15 @@ func (c *Check) InstanceName() string {
 	return c.instName
 }
 
-func (c *Check) Init(cfg *config.Map) error {
+func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error {
+	switch len(inlineArgs) {
+	case 1:
+		c.milterUrl = inlineArgs[0]
+	case 0:
+	default:
+		return fmt.Errorf("%s: unexpected amount of arguments, want 1 or 0", modName)
+	}
+
 	cfg.String("endpoint", false, false, c.milterUrl, &c.milterUrl)
 	cfg.Bool("fail_open", false, false, &c.failOpen)
 	if _, err := cfg.Process(); err != nil {
diff --git a/internal/check/milter/milter_test.go b/internal/check/milter/milter_test.go
index 97978517..50e3954e 100644
--- a/internal/check/milter/milter_test.go
+++ b/internal/check/milter/milter_test.go
@@ -38,7 +38,7 @@ func TestAcceptValidEndpoints(t *testing.T) {
 	} {
 		c := &Check{milterUrl: endpoint}
 
-		err := c.Init(&config.Map{})
+		err := c.Configure(nil, &config.Map{})
 		if err != nil {
 			t.Errorf("Unexpected failure for %s: %v", endpoint, err)
 			return
@@ -52,7 +52,7 @@ func TestRejectInvalidEndpoints(t *testing.T) {
 		"tls:0.0.0.0:10025",
 	} {
 		c := &Check{milterUrl: endpoint}
-		err := c.Init(&config.Map{})
+		err := c.Configure(nil, &config.Map{})
 		if err == nil {
 			t.Errorf("Accepted invalid endpoint: %s", endpoint)
 			return
diff --git a/internal/check/rspamd/rspamd.go b/internal/check/rspamd/rspamd.go
index e6afad68..559c94b6 100644
--- a/internal/check/rspamd/rspamd.go
+++ b/internal/check/rspamd/rspamd.go
@@ -61,22 +61,13 @@ type Check struct {
 	client *http.Client
 }
 
-func New(modName, instName string, _, inlineArgs []string) (module.Module, error) {
+func New(modName, instName string) (module.Module, error) {
 	c := &Check{
 		instName: instName,
 		client:   http.DefaultClient,
 		log:      log.Logger{Name: modName, Debug: log.DefaultLogger.Debug},
 	}
 
-	switch len(inlineArgs) {
-	case 1:
-		c.apiPath = inlineArgs[0]
-	case 0:
-		c.apiPath = "http://127.0.0.1:11333"
-	default:
-		return nil, fmt.Errorf("%s: unexpected amount of inline arguments", modName)
-	}
-
 	return c, nil
 }
 
@@ -88,7 +79,16 @@ func (c *Check) InstanceName() string {
 	return c.instName
 }
 
-func (c *Check) Init(cfg *config.Map) error {
+func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error {
+	switch len(inlineArgs) {
+	case 1:
+		c.apiPath = inlineArgs[0]
+	case 0:
+		c.apiPath = "http://127.0.0.1:11333"
+	default:
+		return fmt.Errorf("%s: unexpected amount of inline arguments", modName)
+	}
+
 	var (
 		tlsConfig tls.Config
 		flags     []string
diff --git a/internal/check/spf/spf.go b/internal/check/spf/spf.go
index c94dd915..a799cd85 100644
--- a/internal/check/spf/spf.go
+++ b/internal/check/spf/spf.go
@@ -60,7 +60,7 @@ type Check struct {
 	resolver dns.Resolver
 }
 
-func New(_, instName string, _, _ []string) (module.Module, error) {
+func New(_, instName string) (module.Module, error) {
 	return &Check{
 		instName: instName,
 		log:      log.Logger{Name: modName},
@@ -76,7 +76,7 @@ func (c *Check) InstanceName() string {
 	return c.instName
 }
 
-func (c *Check) Init(cfg *config.Map) error {
+func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error {
 	cfg.Bool("debug", true, false, &c.log.Debug)
 	cfg.Bool("enforce_early", true, false, &c.enforceEarly)
 	cfg.Custom("none_action", false, false,
diff --git a/internal/check/stateless_check.go b/internal/check/stateless_check.go
index 4c5d2d56..729c106b 100644
--- a/internal/check/stateless_check.go
+++ b/internal/check/stateless_check.go
@@ -152,7 +152,11 @@ func (c *statelessCheck) CheckStateForMsg(ctx context.Context, msgMeta *module.M
 	}, nil
 }
 
-func (c *statelessCheck) Init(cfg *config.Map) error {
+func (c *statelessCheck) Configure(inlineArgs []string, cfg *config.Map) error {
+	if len(inlineArgs) != 0 {
+		return fmt.Errorf("%s: inline arguments are not used", c.modName)
+	}
+
 	cfg.Bool("debug", true, false, &c.logger.Debug)
 	cfg.Custom("fail_action", false, false,
 		func() (interface{}, error) {
@@ -181,10 +185,7 @@ func (c *statelessCheck) InstanceName() string {
 // code doesn't need to know about it. It should assume that it is always "Reject" and hence it should
 // populate Reason field of the result object with the relevant error description.
 func RegisterStatelessCheck(name string, defaultFailAction modconfig.FailAction, connCheck FuncConnCheck, senderCheck FuncSenderCheck, rcptCheck FuncRcptCheck, bodyCheck FuncBodyCheck) {
-	module.Register(name, func(modName, instName string, aliases, inlineArgs []string) (module.Module, error) {
-		if len(inlineArgs) != 0 {
-			return nil, fmt.Errorf("%s: inline arguments are not used", modName)
-		}
+	module.Register(name, func(modName, instName string) (module.Module, error) {
 		return &statelessCheck{
 			modName:  modName,
 			instName: instName,
diff --git a/internal/cli/ctl/moduleinit.go b/internal/cli/ctl/moduleinit.go
index 23e79e5c..ef58d7b4 100644
--- a/internal/cli/ctl/moduleinit.go
+++ b/internal/cli/ctl/moduleinit.go
@@ -25,9 +25,7 @@ import (
 	"os"
 
 	"github.com/foxcpp/maddy"
-	parser "github.com/foxcpp/maddy/framework/cfgparser"
-	"github.com/foxcpp/maddy/framework/config"
-	"github.com/foxcpp/maddy/framework/hooks"
+	"github.com/foxcpp/maddy/framework/container"
 	"github.com/foxcpp/maddy/framework/module"
 	"github.com/foxcpp/maddy/internal/updatepipe"
 	"github.com/urfave/cli/v2"
@@ -39,71 +37,61 @@ func closeIfNeeded(i interface{}) {
 	}
 }
 
-func getCfgBlockModule(ctx *cli.Context) (map[string]interface{}, *maddy.ModInfo, error) {
+func getCfgBlockModule(ctx *cli.Context) (*container.C, module.Module, error) {
 	cfgPath := ctx.String("config")
 	if cfgPath == "" {
 		return nil, nil, cli.Exit("Error: config is required", 2)
 	}
-	cfgFile, err := os.Open(cfgPath)
+
+	c := container.New()
+
+	cfg, err := maddy.ReadConfig(cfgPath)
 	if err != nil {
 		return nil, nil, cli.Exit(fmt.Sprintf("Error: failed to open config: %v", err), 2)
 	}
-	defer cfgFile.Close()
-	cfgNodes, err := parser.Read(cfgFile, cfgFile.Name())
-	if err != nil {
-		return nil, nil, cli.Exit(fmt.Sprintf("Error: failed to parse config: %v", err), 2)
-	}
 
-	globals, cfgNodes, err := maddy.ReadGlobals(cfgNodes)
+	globals, cfgNodes, err := maddy.ReadGlobals(c, cfg)
 	if err != nil {
 		return nil, nil, err
 	}
 
-	if err := maddy.InitDirs(); err != nil {
+	if err := maddy.InitDirs(c); err != nil {
 		return nil, nil, err
 	}
 
-	module.NoRun = true
-	_, mods, err := maddy.RegisterModules(globals, cfgNodes)
+	err = maddy.RegisterModules(c, globals, cfgNodes)
 	if err != nil {
 		return nil, nil, err
 	}
-	defer hooks.RunHooks(hooks.EventShutdown)
 
 	cfgBlock := ctx.String("cfg-block")
 	if cfgBlock == "" {
 		return nil, nil, cli.Exit("Error: cfg-block is required", 2)
 	}
-	var mod maddy.ModInfo
-	for _, m := range mods {
-		if m.Instance.InstanceName() == cfgBlock {
-			mod = m
-			break
+
+	mod, err := c.Modules.Get(cfgBlock)
+	if err != nil {
+		if errors.Is(err, module.ErrInstanceUnknown) {
+			return nil, nil, cli.Exit(fmt.Sprintf("Error: unknown configuration block: %s", cfgBlock), 2)
 		}
-	}
-	if mod.Instance == nil {
-		return nil, nil, cli.Exit(fmt.Sprintf("Error: unknown configuration block: %s", cfgBlock), 2)
+		return nil, nil, err
 	}
 
-	return globals, &mod, nil
+	return c, mod, nil
 }
 
 func openStorage(ctx *cli.Context) (module.Storage, error) {
-	globals, mod, err := getCfgBlockModule(ctx)
+	_, mod, err := getCfgBlockModule(ctx)
 	if err != nil {
 		return nil, err
 	}
 
-	storage, ok := mod.Instance.(module.Storage)
+	storage, ok := mod.(module.Storage)
 	if !ok {
 		return nil, cli.Exit(fmt.Sprintf("Error: configuration block %s is not an IMAP storage", ctx.String("cfg-block")), 2)
 	}
 
-	if err := mod.Instance.Init(config.NewMap(globals, mod.Cfg)); err != nil {
-		return nil, fmt.Errorf("Error: module initialization failed: %w", err)
-	}
-
-	if updStore, ok := mod.Instance.(updatepipe.Backend); ok {
+	if updStore, ok := mod.(updatepipe.Backend); ok {
 		if err := updStore.EnableUpdatePipe(updatepipe.ModePush); err != nil && !errors.Is(err, os.ErrNotExist) {
 			fmt.Fprintf(os.Stderr, "Failed to initialize update pipe, do not remove messages from mailboxes open by clients: %v\n", err)
 		}
@@ -115,19 +103,15 @@ func openStorage(ctx *cli.Context) (module.Storage, error) {
 }
 
 func openUserDB(ctx *cli.Context) (module.PlainUserDB, error) {
-	globals, mod, err := getCfgBlockModule(ctx)
+	_, mod, err := getCfgBlockModule(ctx)
 	if err != nil {
 		return nil, err
 	}
 
-	userDB, ok := mod.Instance.(module.PlainUserDB)
+	userDB, ok := mod.(module.PlainUserDB)
 	if !ok {
 		return nil, cli.Exit(fmt.Sprintf("Error: configuration block %s is not a local credentials store", ctx.String("cfg-block")), 2)
 	}
 
-	if err := mod.Instance.Init(config.NewMap(globals, mod.Cfg)); err != nil {
-		return nil, fmt.Errorf("Error: module initialization failed: %w", err)
-	}
-
 	return userDB, nil
 }
diff --git a/internal/endpoint/dovecot_sasld/dovecot_sasl.go b/internal/endpoint/dovecot_sasld/dovecot_sasl.go
index 77eedd0e..b1bcd159 100644
--- a/internal/endpoint/dovecot_sasld/dovecot_sasl.go
+++ b/internal/endpoint/dovecot_sasld/dovecot_sasl.go
@@ -42,12 +42,13 @@ type Endpoint struct {
 	log      log.Logger
 	saslAuth auth.SASLAuth
 
+	endpoints   []config.Endpoint
 	listenersWg sync.WaitGroup
 
 	srv *dovecotsasl.Server
 }
 
-func New(_ string, addrs []string) (module.Module, error) {
+func New(_ string, addrs []string) (module.LifetimeModule, error) {
 	return &Endpoint{
 		addrs: addrs,
 		saslAuth: auth.SASLAuth{
@@ -65,7 +66,7 @@ func (endp *Endpoint) InstanceName() string {
 	return modName
 }
 
-func (endp *Endpoint) Init(cfg *config.Map) error {
+func (endp *Endpoint) Configure(_ []string, cfg *config.Map) error {
 	cfg.Callback("auth", func(m *config.Map, node config.Node) error {
 		return endp.saslAuth.AddProvider(m, node)
 	})
@@ -97,12 +98,20 @@ func (endp *Endpoint) Init(cfg *config.Map) error {
 			return fmt.Errorf("%s: %v", modName, err)
 		}
 
-		l, err := net.Listen(parsed.Network(), parsed.Address())
+		endp.endpoints = append(endp.endpoints, parsed)
+	}
+
+	return nil
+}
+
+func (endp *Endpoint) Start() error {
+	for _, addr := range endp.endpoints {
+		l, err := net.Listen(addr.Network(), addr.Address())
 		if err != nil {
 			return fmt.Errorf("%s: %v", modName, err)
 		}
-		endp.log.Printf("listening on %v", l.Addr())
 
+		endp.log.Printf("listening on %v", l.Addr())
 		endp.listenersWg.Add(1)
 		go func() {
 			defer endp.listenersWg.Done()
@@ -113,12 +122,13 @@ func (endp *Endpoint) Init(cfg *config.Map) error {
 			}
 		}()
 	}
-
 	return nil
 }
 
-func (endp *Endpoint) Close() error {
-	return endp.srv.Close()
+func (endp *Endpoint) Stop() error {
+	endp.srv.Close()
+	endp.listenersWg.Wait()
+	return nil
 }
 
 func init() {
diff --git a/internal/endpoint/imap/imap.go b/internal/endpoint/imap/imap.go
index 191d93d2..765c429f 100644
--- a/internal/endpoint/imap/imap.go
+++ b/internal/endpoint/imap/imap.go
@@ -51,11 +51,12 @@ import (
 type Endpoint struct {
 	addrs         []string
 	serv          *imapserver.Server
-	listeners     []net.Listener
 	proxyProtocol *proxy_protocol.ProxyProtocol
 	Store         module.Storage
+	tlsConfig     *tls.Config
 
-	tlsConfig   *tls.Config
+	endpoints   []config.Endpoint
+	listeners   []net.Listener
 	listenersWg sync.WaitGroup
 
 	saslAuth auth.SASLAuth
@@ -66,7 +67,7 @@ type Endpoint struct {
 	Log log.Logger
 }
 
-func New(modName string, addrs []string) (module.Module, error) {
+func New(modName string, addrs []string) (module.LifetimeModule, error) {
 	endp := &Endpoint{
 		addrs: addrs,
 		Log:   log.Logger{Name: modName},
@@ -78,7 +79,7 @@ func New(modName string, addrs []string) (module.Module, error) {
 	return endp, nil
 }
 
-func (endp *Endpoint) Init(cfg *config.Map) error {
+func (endp *Endpoint) Configure(_ []string, cfg *config.Map) error {
 	var (
 		insecureAuth bool
 		ioDebug      bool
@@ -106,20 +107,18 @@ func (endp *Endpoint) Init(cfg *config.Map) error {
 		return err
 	}
 
-	if updBe, ok := endp.Store.(updatepipe.Backend); ok {
-		if err := updBe.EnableUpdatePipe(updatepipe.ModeReplicate); err != nil {
-			endp.Log.Error("failed to initialize updates pipe", err)
-		}
-	}
-
 	addresses := make([]config.Endpoint, 0, len(endp.addrs))
 	for _, addr := range endp.addrs {
 		saddr, err := config.ParseEndpoint(addr)
 		if err != nil {
 			return fmt.Errorf("imap: invalid address: %s", addr)
 		}
+		if saddr.IsTLS() && endp.tlsConfig == nil {
+			return errors.New("imap: can't bind on IMAPS endpoint without TLS configuration")
+		}
 		addresses = append(addresses, saddr)
 	}
+	endp.endpoints = addresses
 
 	endp.serv = imapserver.New(endp)
 	endp.serv.AllowInsecureAuth = insecureAuth
@@ -146,7 +145,29 @@ func (endp *Endpoint) Init(cfg *config.Map) error {
 		})
 	}
 
-	return endp.setupListeners(addresses)
+	if endp.serv.AllowInsecureAuth {
+		endp.Log.Println("authentication over unencrypted connections is allowed, this is insecure configuration and should be used only for testing!")
+	}
+	if endp.serv.TLSConfig == nil {
+		endp.Log.Println("TLS is disabled, this is insecure configuration and should be used only for testing!")
+		endp.serv.AllowInsecureAuth = true
+	}
+
+	return nil
+}
+
+func (endp *Endpoint) Start() error {
+	if updBe, ok := endp.Store.(updatepipe.Backend); ok {
+		if err := updBe.EnableUpdatePipe(updatepipe.ModeReplicate); err != nil {
+			endp.Log.Error("failed to initialize updates pipe", err)
+		}
+	}
+
+	if err := endp.setupListeners(endp.endpoints); err != nil {
+		endp.Stop()
+		return err
+	}
+	return nil
 }
 
 func (endp *Endpoint) setupListeners(addresses []config.Endpoint) error {
@@ -171,24 +192,15 @@ func (endp *Endpoint) setupListeners(addresses []config.Endpoint) error {
 		}
 
 		endp.listeners = append(endp.listeners, l)
-
 		endp.listenersWg.Add(1)
 		go func() {
+			defer endp.listenersWg.Done()
 			if err := endp.serv.Serve(l); err != nil && !strings.HasSuffix(err.Error(), "use of closed network connection") {
 				endp.Log.Printf("imap: failed to serve %s: %s", addr, err)
 			}
-			endp.listenersWg.Done()
 		}()
 	}
 
-	if endp.serv.AllowInsecureAuth {
-		endp.Log.Println("authentication over unencrypted connections is allowed, this is insecure configuration and should be used only for testing!")
-	}
-	if endp.serv.TLSConfig == nil {
-		endp.Log.Println("TLS is disabled, this is insecure configuration and should be used only for testing!")
-		endp.serv.AllowInsecureAuth = true
-	}
-
 	return nil
 }
 
@@ -200,7 +212,7 @@ func (endp *Endpoint) InstanceName() string {
 	return "imap"
 }
 
-func (endp *Endpoint) Close() error {
+func (endp *Endpoint) Stop() error {
 	for _, l := range endp.listeners {
 		l.Close()
 	}
diff --git a/internal/endpoint/openmetrics/om.go b/internal/endpoint/openmetrics/om.go
index 874a333d..136d4af5 100644
--- a/internal/endpoint/openmetrics/om.go
+++ b/internal/endpoint/openmetrics/om.go
@@ -34,22 +34,23 @@ import (
 const modName = "openmetrics"
 
 type Endpoint struct {
-	addrs  []string
-	logger log.Logger
+	addrs     []string
+	endpoints []config.Endpoint
+	logger    log.Logger
 
 	listenersWg sync.WaitGroup
 	serv        http.Server
 	mux         *http.ServeMux
 }
 
-func New(_ string, args []string) (module.Module, error) {
+func New(_ string, args []string) (module.LifetimeModule, error) {
 	return &Endpoint{
 		addrs:  args,
 		logger: log.Logger{Name: modName, Debug: log.DefaultLogger.Debug},
 	}, nil
 }
 
-func (e *Endpoint) Init(cfg *config.Map) error {
+func (e *Endpoint) Configure(inlineArgs []string, cfg *config.Map) error {
 	cfg.Bool("debug", false, false, &e.logger.Debug)
 	if _, err := cfg.Process(); err != nil {
 		return err
@@ -67,8 +68,24 @@ func (e *Endpoint) Init(cfg *config.Map) error {
 		if endp.IsTLS() {
 			return fmt.Errorf("%s: TLS is not supported yet", modName)
 		}
+	}
+
+	return nil
+}
+
+func (e *Endpoint) Name() string {
+	return modName
+}
+
+func (e *Endpoint) InstanceName() string {
+	return ""
+}
+
+func (e *Endpoint) Start() error {
+	for _, endp := range e.endpoints {
 		l, err := net.Listen(endp.Network(), endp.Address())
 		if err != nil {
+			e.Stop()
 			return fmt.Errorf("%s: %v", modName, err)
 		}
 
@@ -77,24 +94,15 @@ func (e *Endpoint) Init(cfg *config.Map) error {
 			e.logger.Println("listening on", endp.String())
 			err := e.serv.Serve(l)
 			if err != nil && !errors.Is(err, http.ErrServerClosed) {
-				e.logger.Error("serve failed", err, "endpoint", a)
+				e.logger.Error("serve failed", err, "endpoint", endp)
 			}
 			e.listenersWg.Done()
 		}()
 	}
-
 	return nil
 }
 
-func (e *Endpoint) Name() string {
-	return modName
-}
-
-func (e *Endpoint) InstanceName() string {
-	return ""
-}
-
-func (e *Endpoint) Close() error {
+func (e *Endpoint) Stop() error {
 	if err := e.serv.Close(); err != nil {
 		return err
 	}
diff --git a/internal/endpoint/smtp/session.go b/internal/endpoint/smtp/session.go
index 0eb6427f..ed842f98 100644
--- a/internal/endpoint/smtp/session.go
+++ b/internal/endpoint/smtp/session.go
@@ -274,7 +274,7 @@ func (s *Session) startDelivery(ctx context.Context, from string, opts smtp.Mail
 	mailCtx, mailTask := trace.NewTask(s.msgCtx, "MAIL FROM")
 	defer mailTask.End()
 
-	delivery, err := s.endp.pipeline.Start(mailCtx, msgMeta, cleanFrom)
+	delivery, err := s.endp.pipeline.StartDelivery(mailCtx, msgMeta, cleanFrom)
 	if err != nil {
 		s.msgCtx = nil
 		s.msgTask.End()
diff --git a/internal/endpoint/smtp/smtp.go b/internal/endpoint/smtp/smtp.go
index 004d92e5..7e5f3ab0 100644
--- a/internal/endpoint/smtp/smtp.go
+++ b/internal/endpoint/smtp/smtp.go
@@ -54,6 +54,7 @@ type Endpoint struct {
 	serv          *smtp.Server
 	name          string
 	addrs         []string
+	endpoints     []config.Endpoint
 	listeners     []net.Listener
 	proxyProtocol *proxy_protocol.ProxyProtocol
 	pipeline      *msgpipeline.MsgPipeline
@@ -88,7 +89,7 @@ func (endp *Endpoint) InstanceName() string {
 	return endp.name
 }
 
-func New(modName string, addrs []string) (module.Module, error) {
+func New(modName string, addrs []string) (module.LifetimeModule, error) {
 	endp := &Endpoint{
 		name:       modName,
 		addrs:      addrs,
@@ -104,7 +105,7 @@ func New(modName string, addrs []string) (module.Module, error) {
 	return endp, nil
 }
 
-func (endp *Endpoint) Init(cfg *config.Map) error {
+func (endp *Endpoint) Configure(_ []string, cfg *config.Map) error {
 	endp.serv = smtp.NewServer(endp)
 	endp.serv.ErrorLog = endp.Log
 	endp.serv.LMTP = endp.lmtp
@@ -123,13 +124,7 @@ func (endp *Endpoint) Init(cfg *config.Map) error {
 
 		addresses = append(addresses, saddr)
 	}
-
-	if err := endp.setupListeners(addresses); err != nil {
-		for _, l := range endp.listeners {
-			l.Close()
-		}
-		return err
-	}
+	endp.endpoints = addresses
 
 	allLocal := true
 	for _, addr := range addresses {
@@ -321,6 +316,14 @@ func (endp *Endpoint) setConfig(cfg *config.Map) error {
 	return nil
 }
 
+func (endp *Endpoint) Start() error {
+	if err := endp.setupListeners(endp.endpoints); err != nil {
+		endp.Stop()
+		return err
+	}
+	return nil
+}
+
 func (endp *Endpoint) setupListeners(addresses []config.Endpoint) error {
 	for _, addr := range addresses {
 		var l net.Listener
@@ -419,7 +422,7 @@ func (endp *Endpoint) ConnectionCount() int {
 	return int(endp.sessionCnt.Load())
 }
 
-func (endp *Endpoint) Close() error {
+func (endp *Endpoint) Stop() error {
 	endp.serv.Close()
 	endp.listenersWg.Wait()
 	return nil
diff --git a/internal/endpoint/smtp/smtp_test.go b/internal/endpoint/smtp/smtp_test.go
index fa46c279..d6de22dc 100644
--- a/internal/endpoint/smtp/smtp_test.go
+++ b/internal/endpoint/smtp/smtp_test.go
@@ -89,7 +89,7 @@ func testEndpoint(t *testing.T, modName string, authMod module.PlainAuth, tgt mo
 		})
 	}
 
-	err = endp.Init(config.NewMap(nil, config.Node{
+	err = endp.Configure(nil, config.NewMap(nil, config.Node{
 		Children: cfg,
 	}))
 	if err != nil {
@@ -107,6 +107,10 @@ func testEndpoint(t *testing.T, modName string, authMod module.PlainAuth, tgt mo
 	endp.pipeline.FirstPipeline = true
 	endp.pipeline.Log = testutils.Logger(t, "smtp/pipeline")
 
+	if err := endp.Start(); err != nil {
+		t.Fatal(err)
+	}
+
 	return endp
 }
 
@@ -142,7 +146,7 @@ func submitMsgOpts(t *testing.T, cl *smtp.Client, from string, rcpts []string, o
 func TestSMTPDelivery(t *testing.T) {
 	tgt := testutils.Target{}
 	endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
-	defer endp.Close()
+	defer endp.Stop()
 
 	cl, err := smtp.Dial("127.0.0.1:" + testPort)
 	if err != nil {
@@ -180,7 +184,7 @@ func TestSMTPDelivery(t *testing.T) {
 func TestSMTPDelivery_rDNSError(t *testing.T) {
 	tgt := testutils.Target{}
 	endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
-	defer endp.Close()
+	defer endp.Stop()
 
 	endp.resolver.(*mockdns.Resolver).Zones["1.0.0.127.in-addr.arpa."] = mockdns.Zone{
 		Err: &net.DNSError{
@@ -224,7 +228,7 @@ func TestSMTPDelivery_EarlyCheck_Fail(t *testing.T) {
 			},
 		},
 	}, nil)
-	defer endp.Close()
+	defer endp.Stop()
 
 	cl, err := smtp.Dial("127.0.0.1:" + testPort)
 	if err != nil {
@@ -264,7 +268,7 @@ func TestSMTPDeliver_CheckError(t *testing.T) {
 		},
 	}, nil)
 	endp.deferServerReject = false
-	defer endp.Close()
+	defer endp.Stop()
 
 	cl, err := smtp.Dial("127.0.0.1:" + testPort)
 	if err != nil {
@@ -303,7 +307,7 @@ func TestSMTPDeliver_CheckError_Deferred(t *testing.T) {
 		},
 	}, nil)
 	endp.deferServerReject = true
-	defer endp.Close()
+	defer endp.Stop()
 
 	cl, err := smtp.Dial("127.0.0.1:" + testPort)
 	if err != nil {
@@ -342,7 +346,7 @@ func TestSMTPDeliver_CheckError_Deferred(t *testing.T) {
 func TestSMTPDelivery_Multi(t *testing.T) {
 	tgt := testutils.Target{}
 	endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
-	defer endp.Close()
+	defer endp.Stop()
 
 	cl, err := smtp.Dial("127.0.0.1:" + testPort)
 	if err != nil {
@@ -380,7 +384,7 @@ func TestSMTPDelivery_Multi(t *testing.T) {
 func TestSMTPDelivery_AbortData(t *testing.T) {
 	tgt := testutils.Target{}
 	endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
-	defer endp.Close()
+	defer endp.Stop()
 
 	cl, err := smtp.Dial("127.0.0.1:" + testPort)
 	if err != nil {
@@ -418,7 +422,7 @@ func TestSMTPDelivery_AbortData(t *testing.T) {
 func TestSMTPDelivery_EmptyMessage(t *testing.T) {
 	tgt := testutils.Target{}
 	endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
-	defer endp.Close()
+	defer endp.Stop()
 
 	cl, err := smtp.Dial("127.0.0.1:" + testPort)
 	if err != nil {
@@ -457,7 +461,7 @@ func TestSMTPDelivery_EmptyMessage(t *testing.T) {
 func TestSMTPDelivery_AbortLogout(t *testing.T) {
 	tgt := testutils.Target{}
 	endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
-	defer endp.Close()
+	defer endp.Stop()
 
 	cl, err := smtp.Dial("127.0.0.1:" + testPort)
 	if err != nil {
@@ -488,7 +492,7 @@ func TestSMTPDelivery_AbortLogout(t *testing.T) {
 func TestSMTPDelivery_Reset(t *testing.T) {
 	tgt := testutils.Target{}
 	endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
-	defer endp.Close()
+	defer endp.Stop()
 
 	cl, err := smtp.Dial("127.0.0.1:" + testPort)
 	if err != nil {
@@ -523,7 +527,7 @@ func TestSMTPDelivery_Reset(t *testing.T) {
 func TestSMTPDelivery_SubmissionAuthRequire(t *testing.T) {
 	tgt := testutils.Target{}
 	endp := testEndpoint(t, "submission", &module.Dummy{}, &tgt, nil, nil)
-	defer endp.Close()
+	defer endp.Stop()
 
 	cl, err := smtp.Dial("127.0.0.1:" + testPort)
 	if err != nil {
@@ -539,7 +543,7 @@ func TestSMTPDelivery_SubmissionAuthRequire(t *testing.T) {
 func TestSMTPDelivery_SubmissionAuthOK(t *testing.T) {
 	tgt := testutils.Target{}
 	endp := testEndpoint(t, "submission", &module.Dummy{}, &tgt, nil, nil)
-	defer endp.Close()
+	defer endp.Stop()
 
 	cl, err := smtp.Dial("127.0.0.1:" + testPort)
 	if err != nil {
diff --git a/internal/endpoint/smtp/smtputf8_test.go b/internal/endpoint/smtp/smtputf8_test.go
index b3f57015..2f157aba 100644
--- a/internal/endpoint/smtp/smtputf8_test.go
+++ b/internal/endpoint/smtp/smtputf8_test.go
@@ -43,7 +43,7 @@ func TestSMTPUTF8_MangleStatusMessage(t *testing.T) {
 		},
 	}, nil)
 	endp.deferServerReject = false
-	defer endp.Close()
+	defer endp.Stop()
 	defer testutils.WaitForConnsClose(t, endp.serv)
 
 	cl, err := smtp.Dial("127.0.0.1:" + testPort)
@@ -73,7 +73,7 @@ func TestSMTP_RejectNonASCIIFrom(t *testing.T) {
 	tgt := testutils.Target{}
 	endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
 	endp.deferServerReject = false
-	defer endp.Close()
+	defer endp.Stop()
 	defer testutils.WaitForConnsClose(t, endp.serv)
 
 	cl, err := smtp.Dial("127.0.0.1:" + testPort)
@@ -100,7 +100,7 @@ func TestSMTPUTF8_NormalizeCaseFoldFrom(t *testing.T) {
 	tgt := testutils.Target{}
 	endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
 	endp.deferServerReject = false
-	defer endp.Close()
+	defer endp.Stop()
 	defer testutils.WaitForConnsClose(t, endp.serv)
 
 	cl, err := smtp.Dial("127.0.0.1:" + testPort)
@@ -127,7 +127,7 @@ func TestSMTP_RejectNonASCIIRcpt(t *testing.T) {
 	tgt := testutils.Target{}
 	endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
 	endp.deferServerReject = false
-	defer endp.Close()
+	defer endp.Stop()
 	defer testutils.WaitForConnsClose(t, endp.serv)
 
 	cl, err := smtp.Dial("127.0.0.1:" + testPort)
@@ -154,7 +154,7 @@ func TestSMTPUTF8_NormalizeCaseFoldRcpt(t *testing.T) {
 	tgt := testutils.Target{}
 	endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
 	endp.deferServerReject = false
-	defer endp.Close()
+	defer endp.Stop()
 	defer testutils.WaitForConnsClose(t, endp.serv)
 
 	cl, err := smtp.Dial("127.0.0.1:" + testPort)
@@ -191,7 +191,7 @@ func TestSMTPUTF8_NoMangleStatusMessage(t *testing.T) {
 		},
 	}, nil)
 	endp.deferServerReject = false
-	defer endp.Close()
+	defer endp.Stop()
 	defer testutils.WaitForConnsClose(t, endp.serv)
 
 	cl, err := smtp.Dial("127.0.0.1:" + testPort)
@@ -222,7 +222,7 @@ func TestSMTPUTF8_NoMangleStatusMessage(t *testing.T) {
 func TestSMTPUTF8_Received_EHLO_ALabel(t *testing.T) {
 	tgt := testutils.Target{}
 	endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
-	defer endp.Close()
+	defer endp.Stop()
 	defer testutils.WaitForConnsClose(t, endp.serv)
 
 	cl, err := smtp.Dial("127.0.0.1:" + testPort)
@@ -256,7 +256,7 @@ func TestSMTPUTF8_Received_EHLO_ALabel(t *testing.T) {
 func TestSMTPUTF8_Received_rDNS_ALabel(t *testing.T) {
 	tgt := testutils.Target{}
 	endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
-	defer endp.Close()
+	defer endp.Stop()
 	defer testutils.WaitForConnsClose(t, endp.serv)
 
 	endp.resolver.(*mockdns.Resolver).Zones["1.0.0.127.in-addr.arpa."] = mockdns.Zone{
@@ -290,7 +290,7 @@ func TestSMTPUTF8_Received_rDNS_ALabel(t *testing.T) {
 func TestSMTPUTF8_Received_rDNS_ULabel(t *testing.T) {
 	tgt := testutils.Target{}
 	endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
-	defer endp.Close()
+	defer endp.Stop()
 	defer testutils.WaitForConnsClose(t, endp.serv)
 
 	endp.resolver.(*mockdns.Resolver).Zones["1.0.0.127.in-addr.arpa."] = mockdns.Zone{
@@ -326,7 +326,7 @@ func TestSMTPUTF8_Received_rDNS_ULabel(t *testing.T) {
 func TestSMTPUTF8_Received_EHLO_ULabel(t *testing.T) {
 	tgt := testutils.Target{}
 	endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
-	defer endp.Close()
+	defer endp.Stop()
 	defer testutils.WaitForConnsClose(t, endp.serv)
 
 	cl, err := smtp.Dial("127.0.0.1:" + testPort)
diff --git a/internal/endpoint/smtp/submission_test.go b/internal/endpoint/smtp/submission_test.go
index f257364c..beb4cf5e 100644
--- a/internal/endpoint/smtp/submission_test.go
+++ b/internal/endpoint/smtp/submission_test.go
@@ -56,7 +56,7 @@ func TestSubmissionPrepare(t *testing.T) {
 			cl, _ := smtp.Dial("127.0.0.1:" + testPort)
 			cl.Close()
 
-			endp.Close()
+			endp.Stop()
 		}()
 
 		session, err := endp.NewSession(nil)
diff --git a/internal/imap_filter/command/command.go b/internal/imap_filter/command/command.go
index 58146dca..32f05c03 100644
--- a/internal/imap_filter/command/command.go
+++ b/internal/imap_filter/command/command.go
@@ -61,19 +61,12 @@ func (c *Check) IMAPFilter(accountName string, rcptTo string, msgMeta *module.Ms
 	return c.run(cmd, args, io.MultiReader(bytes.NewReader(buf.Bytes()), bR))
 }
 
-func New(_, instName string, _, inlineArgs []string) (module.Module, error) {
+func New(_, instName string) (module.Module, error) {
 	c := &Check{
 		instName: instName,
 		log:      log.Logger{Name: modName, Debug: log.DefaultLogger.Debug},
 	}
 
-	if len(inlineArgs) == 0 {
-		return nil, errors.New("command: at least one argument is required (command name)")
-	}
-
-	c.cmd = inlineArgs[0]
-	c.cmdArgs = inlineArgs[1:]
-
 	return c, nil
 }
 
@@ -85,7 +78,14 @@ func (c *Check) InstanceName() string {
 	return c.instName
 }
 
-func (c *Check) Init(cfg *config.Map) error {
+func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error {
+	if len(inlineArgs) == 0 {
+		return errors.New("command: at least one argument is required (command name)")
+	}
+
+	c.cmd = inlineArgs[0]
+	c.cmdArgs = inlineArgs[1:]
+
 	// Check whether the inline argument command is usable.
 	if _, err := exec.LookPath(c.cmd); err != nil {
 		return fmt.Errorf("command: %w", err)
diff --git a/internal/imap_filter/group.go b/internal/imap_filter/group.go
index c1c5ecc8..f86ea866 100644
--- a/internal/imap_filter/group.go
+++ b/internal/imap_filter/group.go
@@ -37,7 +37,7 @@ type Group struct {
 	log      log.Logger
 }
 
-func NewGroup(_, instName string, _, _ []string) (module.Module, error) {
+func NewGroup(_, instName string) (module.Module, error) {
 	return &Group{
 		instName: instName,
 		log:      log.Logger{Name: "imap_filters", Debug: log.DefaultLogger.Debug},
@@ -66,7 +66,7 @@ func (g *Group) IMAPFilter(accountName string, rcptTo string, meta *module.MsgMe
 	return finalFolder, finalFlags, nil
 }
 
-func (g *Group) Init(cfg *config.Map) error {
+func (g *Group) Configure(inlineArgs []string, cfg *config.Map) error {
 	for _, node := range cfg.Block.Children {
 		mod, err := modconfig.IMAPFilter(cfg.Globals, append([]string{node.Name}, node.Args...), node)
 		if err != nil {
diff --git a/internal/libdns/acmedns.go b/internal/libdns/acmedns.go
index cb657ee2..d088811d 100644
--- a/internal/libdns/acmedns.go
+++ b/internal/libdns/acmedns.go
@@ -10,7 +10,7 @@ import (
 )
 
 func init() {
-	module.Register("libdns.acmedns", func(modName, instName string, _, _ []string) (module.Module, error) {
+	module.Register("libdns.acmedns", func(modName, instName string) (module.Module, error) {
 		p := acmedns.Provider{}
 		return &ProviderModule{
 			RecordDeleter:  &p,
diff --git a/internal/libdns/alidns.go b/internal/libdns/alidns.go
index cb980beb..9e32399c 100644
--- a/internal/libdns/alidns.go
+++ b/internal/libdns/alidns.go
@@ -10,7 +10,7 @@ import (
 )
 
 func init() {
-	module.Register("libdns.alidns", func(modName, instName string, _, _ []string) (module.Module, error) {
+	module.Register("libdns.alidns", func(modName, instName string) (module.Module, error) {
 		p := alidns.Provider{}
 		return &ProviderModule{
 			RecordDeleter:  &p,
diff --git a/internal/libdns/cloudflare.go b/internal/libdns/cloudflare.go
index 30319538..b69ec75d 100644
--- a/internal/libdns/cloudflare.go
+++ b/internal/libdns/cloudflare.go
@@ -10,7 +10,7 @@ import (
 )
 
 func init() {
-	module.Register("libdns.cloudflare", func(modName, instName string, _, _ []string) (module.Module, error) {
+	module.Register("libdns.cloudflare", func(modName, instName string) (module.Module, error) {
 		p := cloudflare.Provider{}
 		return &ProviderModule{
 			RecordDeleter:  &p,
diff --git a/internal/libdns/digitalocean.go b/internal/libdns/digitalocean.go
index 98b77b07..20369c9a 100644
--- a/internal/libdns/digitalocean.go
+++ b/internal/libdns/digitalocean.go
@@ -10,7 +10,7 @@ import (
 )
 
 func init() {
-	module.Register("libdns.digitalocean", func(modName, instName string, _, _ []string) (module.Module, error) {
+	module.Register("libdns.digitalocean", func(modName, instName string) (module.Module, error) {
 		p := digitalocean.Provider{}
 		return &ProviderModule{
 			RecordDeleter:  &p,
diff --git a/internal/libdns/gandi.go b/internal/libdns/gandi.go
index 62c7c2d0..59a493d5 100644
--- a/internal/libdns/gandi.go
+++ b/internal/libdns/gandi.go
@@ -13,7 +13,7 @@ import (
 )
 
 func init() {
-	module.Register("libdns.gandi", func(modName, instName string, _, _ []string) (module.Module, error) {
+	module.Register("libdns.gandi", func(modName, instName string) (module.Module, error) {
 		p := gandi.Provider{}
 		return &ProviderModule{
 			RecordDeleter:  &p,
diff --git a/internal/libdns/googleclouddns.go b/internal/libdns/googleclouddns.go
index b59c0562..a9bc493e 100644
--- a/internal/libdns/googleclouddns.go
+++ b/internal/libdns/googleclouddns.go
@@ -10,7 +10,7 @@ import (
 )
 
 func init() {
-	module.Register("libdns.googleclouddns", func(modName, instName string, _, _ []string) (module.Module, error) {
+	module.Register("libdns.googleclouddns", func(modName, instName string) (module.Module, error) {
 		p := googleclouddns.Provider{}
 		return &ProviderModule{
 			RecordDeleter:  &p,
diff --git a/internal/libdns/hetzner.go b/internal/libdns/hetzner.go
index b360641f..06620c46 100644
--- a/internal/libdns/hetzner.go
+++ b/internal/libdns/hetzner.go
@@ -10,7 +10,7 @@ import (
 )
 
 func init() {
-	module.Register("libdns.hetzner", func(modName, instName string, _, _ []string) (module.Module, error) {
+	module.Register("libdns.hetzner", func(modName, instName string) (module.Module, error) {
 		p := hetzner.Provider{}
 		return &ProviderModule{
 			RecordDeleter:  &p,
diff --git a/internal/libdns/leaseweb.go b/internal/libdns/leaseweb.go
index 23af12d7..888d0870 100644
--- a/internal/libdns/leaseweb.go
+++ b/internal/libdns/leaseweb.go
@@ -10,7 +10,7 @@ import (
 )
 
 func init() {
-	module.Register("libdns.leaseweb", func(modName, instName string, _, _ []string) (module.Module, error) {
+	module.Register("libdns.leaseweb", func(modName, instName string) (module.Module, error) {
 		p := leaseweb.Provider{}
 		return &ProviderModule{
 			RecordDeleter:  &p,
diff --git a/internal/libdns/metaname.go b/internal/libdns/metaname.go
index 2e37ddd3..2180509b 100644
--- a/internal/libdns/metaname.go
+++ b/internal/libdns/metaname.go
@@ -10,7 +10,7 @@ import (
 )
 
 func init() {
-	module.Register("libdns.metaname", func(modName, instName string, _, _ []string) (module.Module, error) {
+	module.Register("libdns.metaname", func(modName, instName string) (module.Module, error) {
 		p := metaname.Provider{
 			Endpoint: "https://metaname.net/api/1.1",
 		}
diff --git a/internal/libdns/namecheap.go b/internal/libdns/namecheap.go
index 656ebe51..cb38461e 100644
--- a/internal/libdns/namecheap.go
+++ b/internal/libdns/namecheap.go
@@ -10,7 +10,7 @@ import (
 )
 
 func init() {
-	module.Register("libdns.namecheap", func(modName, instName string, _, _ []string) (module.Module, error) {
+	module.Register("libdns.namecheap", func(modName, instName string) (module.Module, error) {
 		p := namecheap.Provider{}
 		return &ProviderModule{
 			RecordDeleter:  &p,
diff --git a/internal/libdns/namedotcom.go b/internal/libdns/namedotcom.go
index 3ce3c51c..0a5c9934 100644
--- a/internal/libdns/namedotcom.go
+++ b/internal/libdns/namedotcom.go
@@ -10,7 +10,7 @@ import (
 )
 
 func init() {
-	module.Register("libdns.namedotcom", func(modName, instName string, _, _ []string) (module.Module, error) {
+	module.Register("libdns.namedotcom", func(modName, instName string) (module.Module, error) {
 		p := namedotcom.Provider{
 			Server: "https://api.name.com",
 		}
diff --git a/internal/libdns/provider_module.go b/internal/libdns/provider_module.go
index 75561501..6df107ff 100644
--- a/internal/libdns/provider_module.go
+++ b/internal/libdns/provider_module.go
@@ -15,7 +15,7 @@ type ProviderModule struct {
 	modName  string
 }
 
-func (p *ProviderModule) Init(cfg *config.Map) error {
+func (p *ProviderModule) Configure(inlineArgs []string, cfg *config.Map) error {
 	p.setConfig(cfg)
 	_, err := cfg.Process()
 	if p.afterConfig != nil {
diff --git a/internal/libdns/rfc2136.go b/internal/libdns/rfc2136.go
index 19751f6f..90eca724 100644
--- a/internal/libdns/rfc2136.go
+++ b/internal/libdns/rfc2136.go
@@ -10,7 +10,7 @@ import (
 )
 
 func init() {
-	module.Register("libdns.rfc2136", func(modName, instName string, _, _ []string) (module.Module, error) {
+	module.Register("libdns.rfc2136", func(modName, instName string) (module.Module, error) {
 		p := rfc2136.Provider{}
 		return &ProviderModule{
 			RecordDeleter:  &p,
diff --git a/internal/libdns/route53.go b/internal/libdns/route53.go
index dd507249..fbb5edf7 100644
--- a/internal/libdns/route53.go
+++ b/internal/libdns/route53.go
@@ -10,7 +10,7 @@ import (
 )
 
 func init() {
-	module.Register("libdns.route53", func(modName, instName string, _, _ []string) (module.Module, error) {
+	module.Register("libdns.route53", func(modName, instName string) (module.Module, error) {
 		p := route53.Provider{}
 		return &ProviderModule{
 			RecordDeleter:  &p,
diff --git a/internal/libdns/vultr.go b/internal/libdns/vultr.go
index 9157258e..e94a2869 100644
--- a/internal/libdns/vultr.go
+++ b/internal/libdns/vultr.go
@@ -10,7 +10,7 @@ import (
 )
 
 func init() {
-	module.Register("libdns.vultr", func(modName, instName string, _, _ []string) (module.Module, error) {
+	module.Register("libdns.vultr", func(modName, instName string) (module.Module, error) {
 		p := vultr.Provider{}
 		return &ProviderModule{
 			RecordDeleter:  &p,
diff --git a/internal/limits/limits.go b/internal/limits/limits.go
index 95d98a2f..7c1a39b0 100644
--- a/internal/limits/limits.go
+++ b/internal/limits/limits.go
@@ -46,13 +46,13 @@ type Group struct {
 	dest   *limiters.BucketSet // BucketSet of MultiLimit
 }
 
-func New(_, instName string, _, _ []string) (module.Module, error) {
+func New(_, instName string) (module.Module, error) {
 	return &Group{
 		instName: instName,
 	}, nil
 }
 
-func (g *Group) Init(cfg *config.Map) error {
+func (g *Group) Configure(inlineArgs []string, cfg *config.Map) error {
 	var (
 		globalL []limiters.L
 		ipL     []func() limiters.L
diff --git a/internal/modify/dkim/dkim.go b/internal/modify/dkim/dkim.go
index ffeed4a2..f0894931 100644
--- a/internal/modify/dkim/dkim.go
+++ b/internal/modify/dkim/dkim.go
@@ -111,23 +111,13 @@ type Modifier struct {
 	log log.Logger
 }
 
-func New(_, instName string, _, inlineArgs []string) (module.Module, error) {
+func New(_, instName string) (module.Module, error) {
 	m := &Modifier{
 		instName: instName,
 		signers:  map[string]crypto.Signer{},
 		log:      log.Logger{Name: "modify.dkim"},
 	}
 
-	if len(inlineArgs) == 0 {
-		return m, nil
-	}
-	if len(inlineArgs) == 1 {
-		return nil, errors.New("modify.dkim: at least two arguments required")
-	}
-
-	m.domains = inlineArgs[0 : len(inlineArgs)-1]
-	m.selector = inlineArgs[len(inlineArgs)-1]
-
 	return m, nil
 }
 
@@ -139,7 +129,16 @@ func (m *Modifier) InstanceName() string {
 	return m.instName
 }
 
-func (m *Modifier) Init(cfg *config.Map) error {
+func (m *Modifier) Configure(inlineArgs []string, cfg *config.Map) error {
+	if len(inlineArgs) != 0 {
+		if len(inlineArgs) == 1 {
+			return errors.New("modify.dkim: at least two arguments required")
+		}
+
+		m.domains = inlineArgs[0 : len(inlineArgs)-1]
+		m.selector = inlineArgs[len(inlineArgs)-1]
+	}
+
 	var (
 		hashName        string
 		keyPathTemplate string
diff --git a/internal/modify/dkim/dkim_test.go b/internal/modify/dkim/dkim_test.go
index d4a9b5ad..172f0f34 100644
--- a/internal/modify/dkim/dkim_test.go
+++ b/internal/modify/dkim/dkim_test.go
@@ -37,14 +37,14 @@ import (
 )
 
 func newTestModifier(t *testing.T, dir, keyAlgo string, domains []string) *Modifier {
-	mod, err := New("", "test", nil, nil)
+	mod, err := New("", "test")
 	if err != nil {
 		t.Fatal(err)
 	}
 	m := mod.(*Modifier)
 	m.log = testutils.Logger(t, m.Name())
 
-	err = m.Init(config.NewMap(nil, config.Node{
+	err = m.Configure(nil, config.NewMap(nil, config.Node{
 		Children: []config.Node{
 			{
 				Name: "domains",
diff --git a/internal/modify/group.go b/internal/modify/group.go
index 3278b0ff..00fbe124 100644
--- a/internal/modify/group.go
+++ b/internal/modify/group.go
@@ -43,7 +43,7 @@ type (
 	}
 )
 
-func (g *Group) Init(cfg *config.Map) error {
+func (g *Group) Configure(inlineArgs []string, cfg *config.Map) error {
 	for _, node := range cfg.Block.Children {
 		mod, err := modconfig.MsgModifier(cfg.Globals, append([]string{node.Name}, node.Args...), node)
 		if err != nil {
@@ -64,7 +64,7 @@ func (g *Group) InstanceName() string {
 	return g.instName
 }
 
-func (g Group) ModStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.ModifierState, error) {
+func (g *Group) ModStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.ModifierState, error) {
 	gs := groupState{}
 	for _, modifier := range g.Modifiers {
 		state, err := modifier.ModStateForMsg(ctx, msgMeta)
@@ -132,7 +132,7 @@ func (gs groupState) Close() error {
 }
 
 func init() {
-	module.Register("modifiers", func(_, instName string, _, _ []string) (module.Module, error) {
+	module.Register("modifiers", func(_, instName string) (module.Module, error) {
 		return &Group{
 			instName: instName,
 		}, nil
diff --git a/internal/modify/replace_addr.go b/internal/modify/replace_addr.go
index 50dd5dff..f305ac95 100644
--- a/internal/modify/replace_addr.go
+++ b/internal/modify/replace_addr.go
@@ -37,20 +37,18 @@ import (
 // If created with modName = "modify.replace_sender", it will change sender address.
 // If created with modName = "modify.replace_rcpt", it will change recipient addresses.
 type replaceAddr struct {
-	modName    string
-	instName   string
-	inlineArgs []string
+	modName  string
+	instName string
 
 	replaceSender bool
 	replaceRcpt   bool
 	table         module.MultiTable
 }
 
-func NewReplaceAddr(modName, instName string, _, inlineArgs []string) (module.Module, error) {
+func NewReplaceAddr(modName, instName string) (module.Module, error) {
 	r := replaceAddr{
 		modName:       modName,
 		instName:      instName,
-		inlineArgs:    inlineArgs,
 		replaceSender: modName == "modify.replace_sender",
 		replaceRcpt:   modName == "modify.replace_rcpt",
 	}
@@ -58,23 +56,23 @@ func NewReplaceAddr(modName, instName string, _, inlineArgs []string) (module.Mo
 	return &r, nil
 }
 
-func (r *replaceAddr) Init(cfg *config.Map) error {
-	return modconfig.ModuleFromNode("table", r.inlineArgs, cfg.Block, cfg.Globals, &r.table)
+func (r *replaceAddr) Configure(inlineArgs []string, cfg *config.Map) error {
+	return modconfig.ModuleFromNode("table", inlineArgs, cfg.Block, cfg.Globals, &r.table)
 }
 
-func (r replaceAddr) Name() string {
+func (r *replaceAddr) Name() string {
 	return r.modName
 }
 
-func (r replaceAddr) InstanceName() string {
+func (r *replaceAddr) InstanceName() string {
 	return r.instName
 }
 
-func (r replaceAddr) ModStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.ModifierState, error) {
+func (r *replaceAddr) ModStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.ModifierState, error) {
 	return r, nil
 }
 
-func (r replaceAddr) RewriteSender(ctx context.Context, mailFrom string) (string, error) {
+func (r *replaceAddr) RewriteSender(ctx context.Context, mailFrom string) (string, error) {
 	if r.replaceSender {
 		results, err := r.rewrite(ctx, mailFrom)
 		if err != nil {
@@ -85,22 +83,22 @@ func (r replaceAddr) RewriteSender(ctx context.Context, mailFrom string) (string
 	return mailFrom, nil
 }
 
-func (r replaceAddr) RewriteRcpt(ctx context.Context, rcptTo string) ([]string, error) {
+func (r *replaceAddr) RewriteRcpt(ctx context.Context, rcptTo string) ([]string, error) {
 	if r.replaceRcpt {
 		return r.rewrite(ctx, rcptTo)
 	}
 	return []string{rcptTo}, nil
 }
 
-func (r replaceAddr) RewriteBody(ctx context.Context, h *textproto.Header, body buffer.Buffer) error {
+func (r *replaceAddr) RewriteBody(ctx context.Context, h *textproto.Header, body buffer.Buffer) error {
 	return nil
 }
 
-func (r replaceAddr) Close() error {
+func (r *replaceAddr) Close() error {
 	return nil
 }
 
-func (r replaceAddr) rewrite(ctx context.Context, val string) ([]string, error) {
+func (r *replaceAddr) rewrite(ctx context.Context, val string) ([]string, error) {
 	normAddr, err := address.ForLookup(val)
 	if err != nil {
 		return []string{val}, fmt.Errorf("malformed address: %v", err)
diff --git a/internal/modify/replace_addr_test.go b/internal/modify/replace_addr_test.go
index e16cfd34..9c3b0b75 100644
--- a/internal/modify/replace_addr_test.go
+++ b/internal/modify/replace_addr_test.go
@@ -31,12 +31,12 @@ func testReplaceAddr(t *testing.T, modName string) {
 	test := func(addr string, expectedMulti []string, aliases map[string][]string) {
 		t.Helper()
 
-		mod, err := NewReplaceAddr(modName, "", nil, []string{"dummy"})
+		mod, err := NewReplaceAddr(modName, "")
 		if err != nil {
 			t.Fatal(err)
 		}
 		m := mod.(*replaceAddr)
-		if err := m.Init(config.NewMap(nil, config.Node{})); err != nil {
+		if err := m.Configure([]string{"dummy"}, config.NewMap(nil, config.Node{})); err != nil {
 			t.Fatal(err)
 		}
 		m.table = testutils.MultiTable{M: aliases}
diff --git a/internal/msgpipeline/check_group.go b/internal/msgpipeline/check_group.go
index 1fd6b245..27bcdb6c 100644
--- a/internal/msgpipeline/check_group.go
+++ b/internal/msgpipeline/check_group.go
@@ -37,7 +37,7 @@ type CheckGroup struct {
 	L        []module.Check
 }
 
-func (cg *CheckGroup) Init(cfg *config.Map) error {
+func (cg *CheckGroup) Configure(inlineArgs []string, cfg *config.Map) error {
 	for _, node := range cfg.Block.Children {
 		chk, err := modconfig.MessageCheck(cfg.Globals, append([]string{node.Name}, node.Args...), node)
 		if err != nil {
@@ -50,16 +50,16 @@ func (cg *CheckGroup) Init(cfg *config.Map) error {
 	return nil
 }
 
-func (CheckGroup) Name() string {
+func (*CheckGroup) Name() string {
 	return "checks"
 }
 
-func (cg CheckGroup) InstanceName() string {
+func (cg *CheckGroup) InstanceName() string {
 	return cg.instName
 }
 
 func init() {
-	module.Register("checks", func(_, instName string, _, _ []string) (module.Module, error) {
+	module.Register("checks", func(_, instName string) (module.Module, error) {
 		return &CheckGroup{
 			instName: instName,
 		}, nil
diff --git a/internal/msgpipeline/dmarc_test.go b/internal/msgpipeline/dmarc_test.go
index f942baf8..e1c5656a 100644
--- a/internal/msgpipeline/dmarc_test.go
+++ b/internal/msgpipeline/dmarc_test.go
@@ -55,7 +55,7 @@ func doTestDelivery(t *testing.T, tgt module.DeliveryTarget, from string, to []s
 		panic(err)
 	}
 
-	delivery, err := tgt.Start(context.Background(), &ctx, from)
+	delivery, err := tgt.StartDelivery(context.Background(), &ctx, from)
 	if err != nil {
 		return encodedID, err
 	}
diff --git a/internal/msgpipeline/module.go b/internal/msgpipeline/module.go
index cf30d221..a951ec88 100644
--- a/internal/msgpipeline/module.go
+++ b/internal/msgpipeline/module.go
@@ -30,14 +30,14 @@ type Module struct {
 	*MsgPipeline
 }
 
-func NewModule(modName, instName string, aliases, inlineArgs []string) (module.Module, error) {
+func NewModule(modName, instName string) (module.Module, error) {
 	return &Module{
 		log:      log.Logger{Name: "msgpipeline"},
 		instName: instName,
 	}, nil
 }
 
-func (m *Module) Init(cfg *config.Map) error {
+func (m *Module) Configure(inlineArgs []string, cfg *config.Map) error {
 	var hostname string
 	cfg.String("hostname", true, true, "", &hostname)
 	cfg.Bool("debug", true, false, &m.log.Debug)
diff --git a/internal/msgpipeline/msgpipeline.go b/internal/msgpipeline/msgpipeline.go
index 388caab1..5caa0899 100644
--- a/internal/msgpipeline/msgpipeline.go
+++ b/internal/msgpipeline/msgpipeline.go
@@ -111,13 +111,13 @@ func (d *MsgPipeline) RunEarlyChecks(ctx context.Context, state *module.ConnStat
 	return eg.Wait()
 }
 
-// Start starts new message delivery, runs connection and sender checks, sender modifiers
+// StartDelivery starts new message delivery, runs connection and sender checks, sender modifiers
 // and selects source block from config to use for handling.
 //
 // Returned module.Delivery implements PartialDelivery. If underlying target doesn't
 // support it, msgpipeline will copy the returned error for all recipients handled
 // by target.
-func (d *MsgPipeline) Start(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) {
+func (d *MsgPipeline) StartDelivery(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) {
 	dd := msgpipelineDelivery{
 		d:                  d,
 		rcptModifiersState: make(map[*rcptBlock]module.ModifierState),
@@ -622,14 +622,14 @@ func (dd *msgpipelineDelivery) getDelivery(ctx context.Context, tgt module.Deliv
 		return delivery_, nil
 	}
 
-	deliveryObj, err := tgt.Start(ctx, dd.msgMeta, dd.sourceAddr)
+	deliveryObj, err := tgt.StartDelivery(ctx, dd.msgMeta, dd.sourceAddr)
 	if err != nil {
-		dd.log.Debugf("tgt.Start(%s) failure, target = %s: %v", dd.sourceAddr, objectName(tgt), err)
+		dd.log.Debugf("tgt.StartDelivery(%s) failure, target = %s: %v", dd.sourceAddr, objectName(tgt), err)
 		return nil, err
 	}
 	delivery_ = &delivery{Delivery: deliveryObj}
 
-	dd.log.Debugf("tgt.Start(%s) ok, target = %s", dd.sourceAddr, objectName(tgt))
+	dd.log.Debugf("tgt.StartDelivery(%s) ok, target = %s", dd.sourceAddr, objectName(tgt))
 
 	dd.deliveries[tgt] = delivery_
 	return delivery_, nil
diff --git a/internal/msgpipeline/msgpipeline_test.go b/internal/msgpipeline/msgpipeline_test.go
index 9899eb03..6d09da00 100644
--- a/internal/msgpipeline/msgpipeline_test.go
+++ b/internal/msgpipeline/msgpipeline_test.go
@@ -380,14 +380,14 @@ func TestMsgPipeline_PerSourceReject(t *testing.T) {
 
 	testutils.DoTestDelivery(t, &d, "sender1@example.com", []string{"rcpt@example.com"})
 
-	_, err := d.Start(context.Background(), &module.MsgMetadata{ID: "testing"}, "sender2@example.com")
+	_, err := d.StartDelivery(context.Background(), &module.MsgMetadata{ID: "testing"}, "sender2@example.com")
 	if err == nil {
-		t.Error("expected error for delivery.Start, got nil")
+		t.Error("expected error for delivery.StartDelivery, got nil")
 	}
 
-	_, err = d.Start(context.Background(), &module.MsgMetadata{ID: "testing"}, "sender2@example.org")
+	_, err = d.StartDelivery(context.Background(), &module.MsgMetadata{ID: "testing"}, "sender2@example.org")
 	if err == nil {
-		t.Error("expected error for delivery.Start, got nil")
+		t.Error("expected error for delivery.StartDelivery, got nil")
 	}
 }
 
@@ -413,9 +413,9 @@ func TestMsgPipeline_PerRcptReject(t *testing.T) {
 		Log: testutils.Logger(t, "msgpipeline"),
 	}
 
-	delivery, err := d.Start(context.Background(), &module.MsgMetadata{ID: "testing"}, "sender@example.com")
+	delivery, err := d.StartDelivery(context.Background(), &module.MsgMetadata{ID: "testing"}, "sender@example.com")
 	if err != nil {
-		t.Fatalf("unexpected Start err: %v", err)
+		t.Fatalf("unexpected StartDelivery err: %v", err)
 	}
 	defer func() {
 		if err := delivery.Abort(context.Background()); err != nil {
@@ -628,7 +628,7 @@ func TestMsgPipeline_MalformedSource(t *testing.T) {
 
 	// Simple checks for violations that can make msgpipeline misbehave.
 	for _, addr := range []string{"not_postmaster_but_no_at_sign", "@no_mailbox", "no_domain@"} {
-		_, err := d.Start(context.Background(), &module.MsgMetadata{ID: "testing"}, addr)
+		_, err := d.StartDelivery(context.Background(), &module.MsgMetadata{ID: "testing"}, addr)
 		if err == nil {
 			t.Errorf("%s is accepted as valid address", addr)
 		}
diff --git a/internal/smtpconn/smtpconn.go b/internal/smtpconn/smtpconn.go
index ec42974d..3c4b4224 100644
--- a/internal/smtpconn/smtpconn.go
+++ b/internal/smtpconn/smtpconn.go
@@ -376,7 +376,7 @@ func (c *C) Rcpt(ctx context.Context, to string, opts smtp.RcptOptions) error {
 		// TODO: DSN support
 	}
 
-	// If necessary, the extension flag is enabled in Start.
+	// If necessary, the extension flag is enabled in StartDelivery.
 	if ok, _ := c.cl.Extension("SMTPUTF8"); !address.IsASCII(to) && !ok {
 		var err error
 		to, err = address.ToASCII(to)
diff --git a/internal/storage/blob/fs/fs.go b/internal/storage/blob/fs/fs.go
index e8c9b389..48e5f21a 100644
--- a/internal/storage/blob/fs/fs.go
+++ b/internal/storage/blob/fs/fs.go
@@ -17,26 +17,27 @@ type FSStore struct {
 	root     string
 }
 
-func New(_, instName string, _, inlineArgs []string) (module.Module, error) {
-	switch len(inlineArgs) {
-	case 0:
-		return &FSStore{instName: instName}, nil
-	case 1:
-		return &FSStore{instName: instName, root: inlineArgs[0]}, nil
-	default:
-		return nil, fmt.Errorf("storage.blob.fs: 1 or 0 arguments expected")
-	}
+func New(_, instName string) (module.Module, error) {
+	return &FSStore{instName: instName}, nil
 }
 
-func (s FSStore) Name() string {
+func (s *FSStore) Name() string {
 	return "storage.blob.fs"
 }
 
-func (s FSStore) InstanceName() string {
+func (s *FSStore) InstanceName() string {
 	return s.instName
 }
 
-func (s *FSStore) Init(cfg *config.Map) error {
+func (s *FSStore) Configure(inlineArgs []string, cfg *config.Map) error {
+	switch len(inlineArgs) {
+	case 0:
+	case 1:
+		s.root = inlineArgs[0]
+	default:
+		return fmt.Errorf("storage.blob.fs: 1 or 0 arguments expected")
+	}
+
 	cfg.String("root", false, false, s.root, &s.root)
 	if _, err := cfg.Process(); err != nil {
 		return err
@@ -91,5 +92,5 @@ func (s *FSStore) Delete(_ context.Context, keys []string) error {
 
 func init() {
 	var _ module.BlobStore = &FSStore{}
-	module.Register(FSStore{}.Name(), New)
+	module.Register((&FSStore{}).Name(), New)
 }
diff --git a/internal/storage/blob/s3/s3.go b/internal/storage/blob/s3/s3.go
index af01d888..075c8368 100644
--- a/internal/storage/blob/s3/s3.go
+++ b/internal/storage/blob/s3/s3.go
@@ -34,18 +34,18 @@ type Store struct {
 	objectPrefix string
 }
 
-func New(_, instName string, _, inlineArgs []string) (module.Module, error) {
-	if len(inlineArgs) != 0 {
-		return nil, fmt.Errorf("%s: expected 0 arguments", modName)
-	}
-
+func New(_, instName string) (module.Module, error) {
 	return &Store{
 		instName: instName,
 		log:      log.Logger{Name: modName},
 	}, nil
 }
 
-func (s *Store) Init(cfg *config.Map) error {
+func (s *Store) Configure(inlineArgs []string, cfg *config.Map) error {
+	if len(inlineArgs) != 0 {
+		return fmt.Errorf("%s: expected 0 arguments", modName)
+	}
+
 	var (
 		secure          bool
 		accessKeyID     string
diff --git a/internal/storage/blob/s3/s3_test.go b/internal/storage/blob/s3/s3_test.go
index 98dd228b..78ba9700 100644
--- a/internal/storage/blob/s3/s3_test.go
+++ b/internal/storage/blob/s3/s3_test.go
@@ -28,7 +28,7 @@ func TestFS(t *testing.T) {
 		}
 
 		st := &Store{instName: "test"}
-		err := st.Init(config.NewMap(map[string]interface{}{}, config.Node{
+		err := st.Configure(nil, config.NewMap(map[string]interface{}{}, config.Node{
 			Children: []config.Node{
 				{
 					Name: "endpoint",
diff --git a/internal/storage/imapsql/delivery.go b/internal/storage/imapsql/delivery.go
index 60cb2e1f..a9ce32e8 100644
--- a/internal/storage/imapsql/delivery.go
+++ b/internal/storage/imapsql/delivery.go
@@ -155,8 +155,8 @@ func (d *delivery) Commit(ctx context.Context) error {
 	return d.d.Commit()
 }
 
-func (store *Storage) Start(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) {
-	defer trace.StartRegion(ctx, "sql/Start").End()
+func (store *Storage) StartDelivery(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) {
+	defer trace.StartRegion(ctx, "sql/StartDelivery").End()
 
 	return &delivery{
 		store:      store,
diff --git a/internal/storage/imapsql/imapsql.go b/internal/storage/imapsql/imapsql.go
index 711a34e1..ddee1358 100644
--- a/internal/storage/imapsql/imapsql.go
+++ b/internal/storage/imapsql/imapsql.go
@@ -28,6 +28,7 @@ package imapsql
 import (
 	"context"
 	"crypto/sha1"
+	"database/sql"
 	"encoding/hex"
 	"errors"
 	"fmt"
@@ -61,8 +62,10 @@ type Storage struct {
 
 	junkMbox string
 
-	driver string
-	dsn    []string
+	driver    string
+	dsn       []string
+	blobStore module.BlobStore
+	opts      *imapsql.Opts
 
 	resolver dns.Resolver
 
@@ -86,24 +89,25 @@ func (store *Storage) InstanceName() string {
 	return store.instName
 }
 
-func New(_, instName string, _, inlineArgs []string) (module.Module, error) {
+func New(_, instName string) (module.Module, error) {
 	store := &Storage{
 		instName: instName,
 		Log:      log.Logger{Name: "imapsql"},
 		resolver: dns.DefaultResolver(),
 	}
+	return store, nil
+}
+
+func (store *Storage) Configure(inlineArgs []string, cfg *config.Map) error {
 	if len(inlineArgs) != 0 {
 		if len(inlineArgs) == 1 {
-			return nil, errors.New("imapsql: expected at least 2 arguments")
+			return errors.New("imapsql: expected at least 2 arguments")
 		}
 
 		store.driver = inlineArgs[0]
 		store.dsn = inlineArgs[1:]
 	}
-	return store, nil
-}
 
-func (store *Storage) Init(cfg *config.Map) error {
 	var (
 		driver            string
 		dsn               []string
@@ -115,7 +119,7 @@ func (store *Storage) Init(cfg *config.Map) error {
 		blobStore module.BlobStore
 	)
 
-	opts := imapsql.Opts{}
+	opts := &imapsql.Opts{}
 	cfg.String("driver", false, false, store.driver, &driver)
 	cfg.StringList("dsn", false, false, store.dsn, &dsn)
 	cfg.Callback("fsstore", func(m *config.Map, node config.Node) error {
@@ -238,9 +242,6 @@ func (store *Storage) Init(cfg *config.Map) error {
 		opts.MaxMsgBytes = new(uint32)
 		*opts.MaxMsgBytes = uint32(appendlimitVal)
 	}
-	var err error
-
-	dsnStr := strings.Join(dsn, " ")
 
 	if len(compression) != 0 {
 		switch compression[0] {
@@ -264,16 +265,33 @@ func (store *Storage) Init(cfg *config.Map) error {
 		}
 	}
 
-	store.Back, err = imapsql.New(driver, dsnStr, ExtBlobStore{Base: blobStore}, opts)
-	if err != nil {
-		return fmt.Errorf("imapsql: %s", err)
+	driverFound := false
+	for _, d := range sql.Drivers() {
+		if d == driver {
+			driverFound = true
+			break
+		}
+	}
+	if !driverFound {
+		return fmt.Errorf("imapsql: unknown driver %q", driver)
 	}
-
-	store.Log.Debugln("go-imap-sql version", imapsql.VersionStr)
 
 	store.driver = driver
 	store.dsn = dsn
+	store.blobStore = blobStore
+	store.opts = opts
+	store.Log.Debugln("go-imap-sql version", imapsql.VersionStr)
+
+	return nil
+}
 
+func (store *Storage) Start() error {
+	dsnStr := strings.Join(store.dsn, " ")
+	var err error
+	store.Back, err = imapsql.New(store.driver, dsnStr, ExtBlobStore{Base: store.blobStore}, *store.opts)
+	if err != nil {
+		return fmt.Errorf("imapsql: %s", err)
+	}
 	return nil
 }
 
@@ -407,7 +425,7 @@ func (store *Storage) Lookup(ctx context.Context, key string) (string, bool, err
 	return "", true, nil
 }
 
-func (store *Storage) Close() error {
+func (store *Storage) Stop() error {
 	// Stop backend from generating new updates.
 	store.Back.Close()
 
diff --git a/internal/table/chain.go b/internal/table/chain.go
index 72eceba0..3b29a1c0 100644
--- a/internal/table/chain.go
+++ b/internal/table/chain.go
@@ -34,14 +34,14 @@ type Chain struct {
 	optional []bool
 }
 
-func NewChain(modName, instName string, _, _ []string) (module.Module, error) {
+func NewChain(modName, instName string) (module.Module, error) {
 	return &Chain{
 		modName:  modName,
 		instName: instName,
 	}, nil
 }
 
-func (s *Chain) Init(cfg *config.Map) error {
+func (s *Chain) Configure(inlineArgs []string, cfg *config.Map) error {
 	cfg.Callback("step", func(m *config.Map, node config.Node) error {
 		var tbl module.Table
 		err := modconfig.ModuleFromNode("table", node.Args, node, m.Globals, &tbl)
diff --git a/internal/table/email_localpart.go b/internal/table/email_localpart.go
index a9d6f067..4371a3e3 100644
--- a/internal/table/email_localpart.go
+++ b/internal/table/email_localpart.go
@@ -32,7 +32,7 @@ type EmailLocalpart struct {
 	allowNonEmail bool
 }
 
-func NewEmailLocalpart(modName, instName string, _, _ []string) (module.Module, error) {
+func NewEmailLocalpart(modName, instName string) (module.Module, error) {
 	return &EmailLocalpart{
 		modName:       modName,
 		instName:      instName,
@@ -40,7 +40,7 @@ func NewEmailLocalpart(modName, instName string, _, _ []string) (module.Module,
 	}, nil
 }
 
-func (s *EmailLocalpart) Init(cfg *config.Map) error {
+func (s *EmailLocalpart) Configure(inlineArgs []string, cfg *config.Map) error {
 	return nil
 }
 
diff --git a/internal/table/email_with_domain.go b/internal/table/email_with_domain.go
index 4eb50b54..62d9c565 100644
--- a/internal/table/email_with_domain.go
+++ b/internal/table/email_with_domain.go
@@ -35,16 +35,17 @@ type EmailWithDomain struct {
 	log      log.Logger
 }
 
-func NewEmailWithDomain(modName, instName string, _, inlineArgs []string) (module.Module, error) {
+func NewEmailWithDomain(modName, instName string) (module.Module, error) {
 	return &EmailWithDomain{
 		modName:  modName,
 		instName: instName,
-		domains:  inlineArgs,
 		log:      log.Logger{Name: modName},
 	}, nil
 }
 
-func (s *EmailWithDomain) Init(cfg *config.Map) error {
+func (s *EmailWithDomain) Configure(inlineArgs []string, cfg *config.Map) error {
+	s.domains = inlineArgs
+
 	for _, d := range s.domains {
 		if !address.ValidDomain(d) {
 			return fmt.Errorf("%s: invalid domain: %s", s.modName, d)
diff --git a/internal/table/file.go b/internal/table/file.go
index a0286a9a..3b7fcc03 100644
--- a/internal/table/file.go
+++ b/internal/table/file.go
@@ -29,7 +29,6 @@ import (
 	"time"
 
 	"github.com/foxcpp/maddy/framework/config"
-	"github.com/foxcpp/maddy/framework/hooks"
 	"github.com/foxcpp/maddy/framework/log"
 	"github.com/foxcpp/maddy/framework/module"
 )
@@ -50,7 +49,7 @@ type File struct {
 	log log.Logger
 }
 
-func NewFile(_, instName string, _, inlineArgs []string) (module.Module, error) {
+func NewFile(_, instName string) (module.Module, error) {
 	m := &File{
 		instName:     instName,
 		m:            make(map[string][]string),
@@ -59,14 +58,6 @@ func NewFile(_, instName string, _, inlineArgs []string) (module.Module, error)
 		log:          log.Logger{Name: FileModName},
 	}
 
-	switch len(inlineArgs) {
-	case 1:
-		m.file = inlineArgs[0]
-	case 0:
-	default:
-		return nil, fmt.Errorf("%s: cannot use multiple files with single %s, use %s multiple times to do so", FileModName, FileModName, FileModName)
-	}
-
 	return m, nil
 }
 
@@ -78,7 +69,15 @@ func (f *File) InstanceName() string {
 	return f.instName
 }
 
-func (f *File) Init(cfg *config.Map) error {
+func (f *File) Configure(inlineArgs []string, cfg *config.Map) error {
+	switch len(inlineArgs) {
+	case 1:
+		f.file = inlineArgs[0]
+	case 0:
+	default:
+		return fmt.Errorf("%s: cannot use multiple files with single %s, use %s multiple times to do so", FileModName, FileModName, FileModName)
+	}
+
 	var file string
 	cfg.Bool("debug", true, false, &f.log.Debug)
 	cfg.String("file", false, false, "", &file)
@@ -100,11 +99,22 @@ func (f *File) Init(cfg *config.Map) error {
 		f.log.Printf("ignoring non-existent file: %s", f.file)
 	}
 
+	return nil
+}
+
+func (f *File) Start() error {
 	go f.reloader()
-	hooks.AddHook(hooks.EventReload, func() {
-		f.forceReload <- struct{}{}
-	})
+	return nil
+}
 
+func (f *File) Reload() error {
+	f.forceReload <- struct{}{}
+	return nil
+}
+
+func (f *File) Stop() error {
+	f.stopReloader <- struct{}{}
+	<-f.stopReloader
 	return nil
 }
 
@@ -181,12 +191,6 @@ func (f *File) reload() {
 	f.mLck.Unlock()
 }
 
-func (f *File) Close() error {
-	f.stopReloader <- struct{}{}
-	<-f.stopReloader
-	return nil
-}
-
 func readFile(path string, out map[string][]string) error {
 	f, err := os.Open(path)
 	if err != nil {
diff --git a/internal/table/file_test.go b/internal/table/file_test.go
index c51620d2..5c7389e3 100644
--- a/internal/table/file_test.go
+++ b/internal/table/file_test.go
@@ -98,15 +98,18 @@ func TestFileReload(t *testing.T) {
 	}
 	f.Close()
 
-	mod, err := NewFile("", "", nil, []string{f.Name()})
+	mod, err := NewFile("", "")
 	if err != nil {
 		t.Fatal(err)
 	}
 	m := mod.(*File)
+	if err := m.Start(); err != nil {
+		t.Fatal(err)
+	}
 	m.log = testutils.Logger(t, "file_map")
-	defer m.Close()
+	defer m.Stop()
 
-	if err := mod.Init(&config.Map{Block: config.Node{}}); err != nil {
+	if err := mod.Configure([]string{f.Name()}, &config.Map{Block: config.Node{}}); err != nil {
 		t.Fatal(err)
 	}
 
@@ -149,15 +152,18 @@ func TestFileReload_Broken(t *testing.T) {
 	}
 	f.Close()
 
-	mod, err := NewFile("", "", nil, []string{f.Name()})
+	mod, err := NewFile("", "")
 	if err != nil {
 		t.Fatal(err)
 	}
 	m := mod.(*File)
+	if err := m.Start(); err != nil {
+		t.Fatal(err)
+	}
 	m.log = testutils.Logger(t, FileModName)
-	defer m.Close()
+	defer m.Stop()
 
-	if err := mod.Init(&config.Map{Block: config.Node{}}); err != nil {
+	if err := mod.Configure([]string{f.Name()}, &config.Map{Block: config.Node{}}); err != nil {
 		t.Fatal(err)
 	}
 
@@ -194,15 +200,18 @@ func TestFileReload_Removed(t *testing.T) {
 	}
 	f.Close()
 
-	mod, err := NewFile("", "", nil, []string{f.Name()})
+	mod, err := NewFile("", "")
 	if err != nil {
 		t.Fatal(err)
 	}
 	m := mod.(*File)
+	if err := m.Start(); err != nil {
+		t.Fatal(err)
+	}
 	m.log = testutils.Logger(t, FileModName)
-	defer m.Close()
+	defer m.Stop()
 
-	if err := mod.Init(&config.Map{Block: config.Node{}}); err != nil {
+	if err := mod.Configure([]string{f.Name()}, &config.Map{Block: config.Node{}}); err != nil {
 		t.Fatal(err)
 	}
 
diff --git a/internal/table/identity.go b/internal/table/identity.go
index c405d3dc..6db17df8 100644
--- a/internal/table/identity.go
+++ b/internal/table/identity.go
@@ -30,14 +30,14 @@ type Identity struct {
 	instName string
 }
 
-func NewIdentity(modName, instName string, _, _ []string) (module.Module, error) {
+func NewIdentity(modName, instName string) (module.Module, error) {
 	return &Identity{
 		modName:  modName,
 		instName: instName,
 	}, nil
 }
 
-func (s *Identity) Init(cfg *config.Map) error {
+func (s *Identity) Configure(inlineArgs []string, cfg *config.Map) error {
 	return nil
 }
 
diff --git a/internal/table/regexp.go b/internal/table/regexp.go
index 069be6ce..0c22cd8a 100644
--- a/internal/table/regexp.go
+++ b/internal/table/regexp.go
@@ -29,9 +29,8 @@ import (
 )
 
 type Regexp struct {
-	modName    string
-	instName   string
-	inlineArgs []string
+	modName  string
+	instName string
 
 	re           *regexp.Regexp
 	replacements []string
@@ -39,15 +38,14 @@ type Regexp struct {
 	expandPlaceholders bool
 }
 
-func NewRegexp(modName, instName string, _, inlineArgs []string) (module.Module, error) {
+func NewRegexp(modName, instName string) (module.Module, error) {
 	return &Regexp{
-		modName:    modName,
-		instName:   instName,
-		inlineArgs: inlineArgs,
+		modName:  modName,
+		instName: instName,
 	}, nil
 }
 
-func (r *Regexp) Init(cfg *config.Map) error {
+func (r *Regexp) Configure(inlineArgs []string, cfg *config.Map) error {
 	var (
 		fullMatch       bool
 		caseInsensitive bool
@@ -59,9 +57,9 @@ func (r *Regexp) Init(cfg *config.Map) error {
 		return err
 	}
 
-	regex := r.inlineArgs[0]
-	if len(r.inlineArgs) > 1 {
-		r.replacements = r.inlineArgs[1:]
+	regex := inlineArgs[0]
+	if len(inlineArgs) > 1 {
+		r.replacements = inlineArgs[1:]
 	}
 
 	if fullMatch {
diff --git a/internal/table/sql_query.go b/internal/table/sql_query.go
index c15f710a..96bce8f0 100644
--- a/internal/table/sql_query.go
+++ b/internal/table/sql_query.go
@@ -32,6 +32,7 @@ import (
 type SQL struct {
 	modName  string
 	instName string
+	prepare  func() error
 
 	namedArgs bool
 
@@ -43,7 +44,7 @@ type SQL struct {
 	del    *sql.Stmt
 }
 
-func NewSQL(modName, instName string, _, _ []string) (module.Module, error) {
+func NewSQL(modName, instName string) (module.Module, error) {
 	return &SQL{
 		modName:  modName,
 		instName: instName,
@@ -58,7 +59,7 @@ func (s *SQL) InstanceName() string {
 	return s.instName
 }
 
-func (s *SQL) Init(cfg *config.Map) error {
+func (s *SQL) Configure(inlineArgs []string, cfg *config.Map) error {
 	var (
 		driver      string
 		initQueries []string
@@ -94,46 +95,52 @@ func (s *SQL) Init(cfg *config.Map) error {
 		return config.NodeErr(cfg.Block, "failed to open db: %v", err)
 	}
 	s.db = db
-
-	for _, init := range initQueries {
-		if _, err := db.Exec(init); err != nil {
-			return config.NodeErr(cfg.Block, "init query failed: %v", err)
+	s.prepare = func() error {
+		for _, init := range initQueries {
+			if _, err := db.Exec(init); err != nil {
+				return config.NodeErr(cfg.Block, "init query failed: %v", err)
+			}
 		}
-	}
 
-	s.lookup, err = db.Prepare(lookupQuery)
-	if err != nil {
-		return config.NodeErr(cfg.Block, "failed to prepare lookup query: %v", err)
-	}
-	if addQuery != "" {
-		s.add, err = db.Prepare(addQuery)
+		s.lookup, err = db.Prepare(lookupQuery)
 		if err != nil {
-			return config.NodeErr(cfg.Block, "failed to prepare add query: %v", err)
+			return fmt.Errorf("failed to prepare lookup query: %v", err)
 		}
-	}
-	if listQuery != "" {
-		s.list, err = db.Prepare(listQuery)
-		if err != nil {
-			return config.NodeErr(cfg.Block, "failed to prepare list query: %v", err)
+		if addQuery != "" {
+			s.add, err = db.Prepare(addQuery)
+			if err != nil {
+				return fmt.Errorf("failed to prepare add query: %v", err)
+			}
 		}
-	}
-	if setQuery != "" {
-		s.set, err = db.Prepare(setQuery)
-		if err != nil {
-			return config.NodeErr(cfg.Block, "failed to prepare set query: %v", err)
+		if listQuery != "" {
+			s.list, err = db.Prepare(listQuery)
+			if err != nil {
+				return config.NodeErr(cfg.Block, "failed to prepare list query: %v", err)
+			}
 		}
-	}
-	if removeQuery != "" {
-		s.del, err = db.Prepare(removeQuery)
-		if err != nil {
-			return config.NodeErr(cfg.Block, "failed to prepare del query: %v", err)
+		if setQuery != "" {
+			s.set, err = db.Prepare(setQuery)
+			if err != nil {
+				return config.NodeErr(cfg.Block, "failed to prepare set query: %v", err)
+			}
+		}
+		if removeQuery != "" {
+			s.del, err = db.Prepare(removeQuery)
+			if err != nil {
+				return config.NodeErr(cfg.Block, "failed to prepare del query: %v", err)
+			}
 		}
+		return nil
 	}
 
 	return nil
 }
 
-func (s *SQL) Close() error {
+func (s *SQL) Start() error {
+	return s.prepare()
+}
+
+func (s *SQL) Stop() error {
 	s.lookup.Close()
 	return s.db.Close()
 }
diff --git a/internal/table/sql_query_test.go b/internal/table/sql_query_test.go
index fd160f76..99269c62 100644
--- a/internal/table/sql_query_test.go
+++ b/internal/table/sql_query_test.go
@@ -33,12 +33,12 @@ import (
 
 func TestSQL(t *testing.T) {
 	path := testutils.Dir(t)
-	mod, err := NewSQL("sql_table", "", nil, nil)
+	mod, err := NewSQL("sql_table", "")
 	if err != nil {
 		t.Fatal("Module create failed:", err)
 	}
 	tbl := mod.(*SQL)
-	err = tbl.Init(config.NewMap(nil, config.Node{
+	err = tbl.Configure(nil, config.NewMap(nil, config.Node{
 		Children: []config.Node{
 			{
 				Name: "driver",
@@ -66,6 +66,9 @@ func TestSQL(t *testing.T) {
 	if err != nil {
 		t.Fatal("Init failed:", err)
 	}
+	if err := tbl.Start(); err != nil {
+		t.Fatal(err)
+	}
 
 	check := func(key, res string, ok, fail bool) {
 		t.Helper()
diff --git a/internal/table/sql_table.go b/internal/table/sql_table.go
index e793dc48..edadb919 100644
--- a/internal/table/sql_table.go
+++ b/internal/table/sql_table.go
@@ -34,7 +34,7 @@ type SQLTable struct {
 	wrapped *SQL
 }
 
-func NewSQLTable(modName, instName string, _, _ []string) (module.Module, error) {
+func NewSQLTable(modName, instName string) (module.Module, error) {
 	return &SQLTable{
 		modName:  modName,
 		instName: instName,
@@ -54,7 +54,7 @@ func (s *SQLTable) InstanceName() string {
 	return s.instName
 }
 
-func (s *SQLTable) Init(cfg *config.Map) error {
+func (s *SQLTable) Configure(inlineArgs []string, cfg *config.Map) error {
 	var (
 		driver      string
 		dsnParts    []string
@@ -99,7 +99,7 @@ func (s *SQLTable) Init(cfg *config.Map) error {
 		delQuery = fmt.Sprintf("DELETE FROM %s WHERE %s = $1", tableName, keyColumn)
 	}
 
-	return s.wrapped.Init(config.NewMap(cfg.Globals, config.Node{
+	return s.wrapped.Configure(nil, config.NewMap(cfg.Globals, config.Node{
 		Children: []config.Node{
 			{
 				Name: "driver",
@@ -144,8 +144,10 @@ func (s *SQLTable) Init(cfg *config.Map) error {
 	}))
 }
 
-func (s *SQLTable) Close() error {
-	return s.wrapped.Close()
+func (s *SQLTable) Start() error { return s.wrapped.Start() }
+
+func (s *SQLTable) Stop() error {
+	return s.wrapped.Stop()
 }
 
 func (s *SQLTable) Lookup(ctx context.Context, val string) (string, bool, error) {
diff --git a/internal/table/static.go b/internal/table/static.go
index e55ffd58..4444f991 100644
--- a/internal/table/static.go
+++ b/internal/table/static.go
@@ -32,7 +32,7 @@ type Static struct {
 	m map[string][]string
 }
 
-func NewStatic(modName, instName string, _, _ []string) (module.Module, error) {
+func NewStatic(modName, instName string) (module.Module, error) {
 	return &Static{
 		modName:  modName,
 		instName: instName,
@@ -40,7 +40,7 @@ func NewStatic(modName, instName string, _, _ []string) (module.Module, error) {
 	}, nil
 }
 
-func (s *Static) Init(cfg *config.Map) error {
+func (s *Static) Configure(inlineArgs []string, cfg *config.Map) error {
 	cfg.Callback("entry", func(_ *config.Map, node config.Node) error {
 		if len(node.Args) < 2 {
 			return config.NodeErr(node, "expected at least one value")
diff --git a/internal/target/queue/queue.go b/internal/target/queue/queue.go
index 264a70fb..663046b3 100644
--- a/internal/target/queue/queue.go
+++ b/internal/target/queue/queue.go
@@ -30,7 +30,7 @@ All scheduled deliveries are attempted to the configured DeliveryTarget.
 All metadata is preserved on disk.
 
 Failure status is determined on per-recipient basis:
-  - Delivery.Start fail handled as a failure for all recipients.
+  - Delivery.StartDelivery fail handled as a failure for all recipients.
   - Delivery.AddRcpt fail handled as a failure for the corresponding recipient.
   - Delivery.Body fail handled as a failure for all recipients.
   - If Delivery implements PartialDelivery, then
@@ -133,6 +133,7 @@ type Queue struct {
 	initialRetryTime time.Duration
 	retryTimeScale   float64
 	maxTries         int
+	maxParallelism   int
 
 	// If any delivery is scheduled in less than postInitDelay
 	// after Init, its delay will be increased by postInitDelay.
@@ -188,7 +189,7 @@ type queueSlot struct {
 	Body buffer.Buffer
 }
 
-func NewQueue(_, instName string, _, inlineArgs []string) (module.Module, error) {
+func NewQueue(_, instName string) (module.Module, error) {
 	q := &Queue{
 		name:             instName,
 		initialRetryTime: 15 * time.Minute,
@@ -196,22 +197,22 @@ func NewQueue(_, instName string, _, inlineArgs []string) (module.Module, error)
 		postInitDelay:    10 * time.Second,
 		Log:              log.Logger{Name: "queue"},
 	}
+	return q, nil
+}
+
+func (q *Queue) Configure(inlineArgs []string, cfg *config.Map) error {
 	switch len(inlineArgs) {
 	case 0:
 		// Not inline definition.
 	case 1:
 		q.location = inlineArgs[0]
 	default:
-		return nil, errors.New("queue: wrong amount of inline arguments")
+		return errors.New("queue: wrong amount of inline arguments")
 	}
-	return q, nil
-}
 
-func (q *Queue) Init(cfg *config.Map) error {
-	var maxParallelism int
 	cfg.Bool("debug", true, false, &q.Log.Debug)
 	cfg.Int("max_tries", false, false, 20, &q.maxTries)
-	cfg.Int("max_parallelism", false, false, 16, &maxParallelism)
+	cfg.Int("max_parallelism", false, false, 16, &q.maxParallelism)
 	cfg.String("location", false, false, q.location, &q.location)
 	cfg.Custom("target", false, true, nil, modconfig.DeliveryDirective, &q.Target)
 	cfg.String("hostname", true, true, "", &q.hostname)
@@ -242,8 +243,11 @@ func (q *Queue) Init(cfg *config.Map) error {
 	if err := os.MkdirAll(q.location, os.ModePerm); err != nil {
 		return err
 	}
+	return nil
+}
 
-	return q.start(maxParallelism)
+func (q *Queue) Start() error {
+	return q.start(q.maxParallelism)
 }
 
 func (q *Queue) start(maxParallelism int) error {
@@ -259,7 +263,7 @@ func (q *Queue) start(maxParallelism int) error {
 	return nil
 }
 
-func (q *Queue) Close() error {
+func (q *Queue) Stop() error {
 	q.wheel.Close()
 	q.deliveryWg.Wait()
 
@@ -473,16 +477,16 @@ func (q *Queue) deliver(meta *QueueMetadata, header textproto.Header, body buffe
 	defer msgTask.End()
 
 	mailCtx, mailTask := trace.NewTask(msgCtx, "MAIL FROM")
-	delivery, err := q.Target.Start(mailCtx, msgMeta, meta.From)
+	delivery, err := q.Target.StartDelivery(mailCtx, msgMeta, meta.From)
 	mailTask.End()
 	if err != nil {
-		dl.Debugf("target.Start failed: %v", err)
+		dl.Debugf("target.StartDelivery failed: %v", err)
 		for _, rcpt := range meta.To {
 			perr.Errs[rcpt] = err
 		}
 		return perr
 	}
-	dl.Debugf("target.Start OK")
+	dl.Debugf("target.StartDelivery OK")
 
 	var acceptedRcpts []string
 	for _, rcpt := range meta.To {
@@ -605,7 +609,7 @@ func (qd *queueDelivery) Commit(ctx context.Context) error {
 	return nil
 }
 
-func (q *Queue) Start(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) {
+func (q *Queue) StartDelivery(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) {
 	meta := &QueueMetadata{
 		MsgMeta:      msgMeta,
 		From:         mailFrom,
@@ -958,7 +962,7 @@ func (q *Queue) emitDSN(meta *QueueMetadata, header textproto.Header, failedRcpt
 	defer msgTask.End()
 
 	mailCtx, mailTask := trace.NewTask(msgCtx, "MAIL FROM")
-	dsnDelivery, err := q.dsnPipeline.Start(mailCtx, dsnMeta, "")
+	dsnDelivery, err := q.dsnPipeline.StartDelivery(mailCtx, dsnMeta, "")
 	mailTask.End()
 	if err != nil {
 		dl.Error("failed to enqueue DSN", err, "dsn_id", dsnID)
diff --git a/internal/target/queue/queue_test.go b/internal/target/queue/queue_test.go
index ff9a4f6a..205ba891 100644
--- a/internal/target/queue/queue_test.go
+++ b/internal/target/queue/queue_test.go
@@ -51,13 +51,13 @@ func newTestQueue(t *testing.T, target module.DeliveryTarget) *Queue {
 
 func cleanQueue(t *testing.T, q *Queue) {
 	t.Log("--- queue.Close")
-	if err := q.Close(); err != nil {
+	if err := q.Stop(); err != nil {
 		t.Fatal("queue.Close:", err)
 	}
 }
 
 func newTestQueueDir(t *testing.T, target module.DeliveryTarget, dir string) *Queue {
-	mod, _ := NewQueue("", "queue", nil, nil)
+	mod, _ := NewQueue("", "queue")
 	q := mod.(*Queue)
 	q.initialRetryTime = 0
 	q.retryTimeScale = 1
@@ -159,7 +159,7 @@ func (utd *unreliableTargetDelivery) Commit(ctx context.Context) error {
 	return nil
 }
 
-func (ut *unreliableTarget) Start(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) {
+func (ut *unreliableTarget) StartDelivery(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) {
 	if ut.bodyFailuresPartial != nil {
 		return &unreliableTargetDeliveryPartial{
 			&unreliableTargetDelivery{
@@ -245,7 +245,7 @@ func TestQueueDelivery(t *testing.T) {
 
 	// Wait for the delivery to complete and stop processing.
 	msg := readMsgChanTimeout(t, dt.committed, 5*time.Second)
-	q.Close()
+	q.Stop()
 
 	testutils.CheckMsgID(t, msg, "tester@example.com", []string{"tester1@example.org", "tester2@example.org"}, "")
 
@@ -269,7 +269,7 @@ func TestQueueDelivery_PermanentFail_NonPartial(t *testing.T) {
 
 	// Queue will abort a delivery if it fails for all recipients.
 	readMsgChanTimeout(t, dt.aborted, 5*time.Second)
-	q.Close()
+	q.Stop()
 
 	// Delivery is failed permanently, hence no retry should be rescheduled.
 	checkQueueDir(t, q, []string{})
@@ -296,7 +296,7 @@ func TestQueueDelivery_PermanentFail_Partial(t *testing.T) {
 	// Here delivery fails for recipients too, but this is reported using PartialDelivery.
 
 	readMsgChanTimeout(t, dt.aborted, 5*time.Second)
-	q.Close()
+	q.Stop()
 	checkQueueDir(t, q, []string{})
 }
 
@@ -322,7 +322,7 @@ func TestQueueDelivery_TemporaryFail(t *testing.T) {
 	msg := readMsgChanTimeout(t, dt.committed, 5*time.Second)
 	testutils.CheckMsgID(t, msg, "tester@example.com", []string{"tester1@example.org", "tester2@example.org"}, "")
 
-	q.Close()
+	q.Stop()
 	// No more retries scheduled, queue storage is clear.
 	defer checkQueueDir(t, q, []string{})
 }
@@ -355,7 +355,7 @@ func TestQueueDelivery_TemporaryFail_Partial(t *testing.T) {
 	msg = readMsgChanTimeout(t, dt.committed, 5000*time.Second)
 	testutils.CheckMsgID(t, msg, "tester@example.com", []string{"tester2@example.org"}, "")
 
-	q.Close()
+	q.Stop()
 	// No more retries scheduled, queue storage is clear.
 	checkQueueDir(t, q, []string{})
 }
@@ -395,7 +395,7 @@ func TestQueueDelivery_MultipleAttempts(t *testing.T) {
 	msg = readMsgChanTimeout(t, dt.committed, 5*time.Second)
 	testutils.CheckMsgID(t, msg, "tester@example.com", []string{"tester2@example.org"}, "")
 
-	q.Close()
+	q.Stop()
 	// No more retries should be scheduled.
 	checkQueueDir(t, q, []string{})
 }
@@ -420,7 +420,7 @@ func TestQueueDelivery_PermanentRcptReject(t *testing.T) {
 	msg := readMsgChanTimeout(t, dt.committed, 5*time.Second)
 	testutils.CheckMsgID(t, msg, "tester@example.org", []string{"tester2@example.org"}, "")
 
-	q.Close()
+	q.Stop()
 	// No more retries should be scheduled.
 	checkQueueDir(t, q, []string{})
 }
@@ -454,7 +454,7 @@ func TestQueueDelivery_TemporaryRcptReject(t *testing.T) {
 	msg = readMsgChanTimeout(t, dt.committed, 5*time.Second)
 	testutils.CheckMsgID(t, msg, "tester@example.com", []string{"tester1@example.org"}, "")
 
-	q.Close()
+	q.Stop()
 	// No more retries should be scheduled.
 	checkQueueDir(t, q, []string{})
 }
@@ -488,7 +488,7 @@ func TestQueueDelivery_SerializationRoundtrip(t *testing.T) {
 	testutils.CheckMsgID(t, msg, "tester@example.com", []string{"tester2@example.org"}, "")
 
 	// Then stop it.
-	q.Close()
+	q.Stop()
 
 	// Make sure it is saved.
 	checkQueueDir(t, q, []string{deliveryID})
@@ -501,7 +501,7 @@ func TestQueueDelivery_SerializationRoundtrip(t *testing.T) {
 	testutils.CheckMsgID(t, msg, "tester@example.com", []string{"tester1@example.org"}, "")
 
 	// Close it again.
-	q.Close()
+	q.Stop()
 	// No more retries should be scheduled.
 	checkQueueDir(t, q, []string{})
 }
@@ -535,7 +535,7 @@ func TestQueueDelivery_DeserlizationCleanUp(t *testing.T) {
 		msg := readMsgChanTimeout(t, dt.committed, 5*time.Second)
 		testutils.CheckMsgID(t, msg, "tester@example.com", []string{"tester2@example.org"}, "")
 
-		q.Close()
+		q.Stop()
 
 		if err := os.Remove(filepath.Join(q.location, deliveryID+fileSuffix)); err != nil {
 			t.Fatal(err)
@@ -543,7 +543,7 @@ func TestQueueDelivery_DeserlizationCleanUp(t *testing.T) {
 
 		// Dangling files should be removed during load.
 		q = newTestQueueDir(t, &dt, q.location)
-		q.Close()
+		q.Stop()
 
 		// Nothing should be left.
 		checkQueueDir(t, q, []string{})
@@ -606,9 +606,9 @@ func TestQueueDelivery_AbortNoDangling(t *testing.T) {
 		DontTraceSender: true,
 		ID:              encodedID,
 	}
-	delivery, err := q.Start(context.Background(), &ctx, "test3@example.org")
+	delivery, err := q.StartDelivery(context.Background(), &ctx, "test3@example.org")
 	if err != nil {
-		t.Fatalf("unexpected Start err: %v", err)
+		t.Fatalf("unexpected StartDelivery err: %v", err)
 	}
 	for _, rcpt := range [...]string{"test@example.org", "test2@example.org"} {
 		if err := delivery.AddRcpt(context.Background(), rcpt, smtp.RcptOptions{}); err != nil {
@@ -786,9 +786,9 @@ func TestQueueDSN_RcptRewrite(t *testing.T) {
 		},
 		ID: encodedID,
 	}
-	delivery, err := q.Start(context.Background(), &ctx, "test3@example.org")
+	delivery, err := q.StartDelivery(context.Background(), &ctx, "test3@example.org")
 	if err != nil {
-		t.Fatalf("unexpected Start err: %v", err)
+		t.Fatalf("unexpected StartDelivery err: %v", err)
 	}
 	for _, rcpt := range [...]string{"test@example.org", "test2@example.org"} {
 		if err := delivery.AddRcpt(context.Background(), rcpt, smtp.RcptOptions{}); err != nil {
diff --git a/internal/target/remote/mxauth_test.go b/internal/target/remote/mxauth_test.go
index 2bd8d2d4..e332bdb4 100644
--- a/internal/target/remote/mxauth_test.go
+++ b/internal/target/remote/mxauth_test.go
@@ -63,7 +63,7 @@ func TestRemoteDelivery_AuthMX_MTASTS(t *testing.T) {
 		testSTSPolicy(t, zones, mtastsGet),
 	})
 	tgt.tlsConfig = clientCfg
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"})
 	be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"})
@@ -108,7 +108,7 @@ func TestRemoteDelivery_MTASTS_SkipNonMatching(t *testing.T) {
 		&localPolicy{minMXLevel: module.MX_MTASTS},
 	})
 	tgt.tlsConfig = clientCfg
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"})
 	be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"})
@@ -148,7 +148,7 @@ func TestRemoteDelivery_AuthMX_MTASTS_Fail(t *testing.T) {
 		&localPolicy{minMXLevel: module.MX_MTASTS},
 	})
 	tgt.tlsConfig = clientCfg
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	_, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"})
 	if err == nil {
@@ -189,7 +189,7 @@ func TestRemoteDelivery_AuthMX_MTASTS_NoTLS(t *testing.T) {
 		testSTSPolicy(t, zones, mtastsGet),
 		&localPolicy{minMXLevel: module.MX_MTASTS},
 	})
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	_, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"})
 	if err == nil {
@@ -230,7 +230,7 @@ func TestRemoteDelivery_AuthMX_MTASTS_RequirePKIX(t *testing.T) {
 		testSTSPolicy(t, zones, mtastsGet),
 		&localPolicy{minMXLevel: module.MX_MTASTS},
 	})
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	_, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"})
 	if err == nil {
@@ -280,7 +280,7 @@ func TestRemoteDelivery_AuthMX_MTASTS_NoPolicy(t *testing.T) {
 		testSTSPolicy(t, zones, mtastsGet),
 		&localPolicy{minMXLevel: module.MX_MTASTS},
 	})
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	_, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"})
 	if err == nil {
@@ -322,7 +322,7 @@ func TestRemoteDelivery_AuthMX_DNSSEC(t *testing.T) {
 	extResolver.Cfg.Port = strconv.Itoa(addr.Port)
 
 	tgt := testTarget(t, zones, extResolver, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"})
 	be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"})
@@ -363,7 +363,7 @@ func TestRemoteDelivery_AuthMX_DNSSEC_Fail(t *testing.T) {
 	tgt := testTarget(t, zones, extResolver, []module.MXAuthPolicy{
 		&localPolicy{minMXLevel: module.MX_DNSSEC},
 	})
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	_, err = testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"})
 	if err == nil {
@@ -405,7 +405,7 @@ func TestRemoteDelivery_REQUIRETLS(t *testing.T) {
 		testSTSPolicy(t, zones, mtastsGet),
 	})
 	tgt.tlsConfig = clientCfg
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	testutils.DoTestDeliveryMeta(t, tgt, "test@example.com", []string{"test@example.invalid"}, &module.MsgMetadata{
 		OriginalFrom: "test@example.com",
@@ -446,7 +446,7 @@ func TestRemoteDelivery_REQUIRETLS_Fail(t *testing.T) {
 		testSTSPolicy(t, zones, mtastsGet),
 	})
 	tgt.tlsConfig = clientCfg
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	if _, err := testutils.DoTestDeliveryErrMeta(t, tgt, "test@example.com", []string{"test@example.invalid"}, &module.MsgMetadata{
 		OriginalFrom: "test@example.com",
@@ -492,7 +492,7 @@ func TestRemoteDelivery_REQUIRETLS_Relaxed(t *testing.T) {
 	})
 	tgt.relaxedREQUIRETLS = true
 	tgt.tlsConfig = clientCfg
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	testutils.DoTestDeliveryMeta(t, tgt, "test@example.com", []string{"test@example.invalid"}, &module.MsgMetadata{
 		OriginalFrom: "test@example.com",
@@ -529,7 +529,7 @@ func TestRemoteDelivery_REQUIRETLS_Relaxed_NoMXAuth(t *testing.T) {
 	})
 	tgt.relaxedREQUIRETLS = true
 	tgt.tlsConfig = clientCfg
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	if _, err := testutils.DoTestDeliveryErrMeta(t, tgt, "test@example.com", []string{"test@example.invalid"}, &module.MsgMetadata{
 		OriginalFrom: "test@example.com",
@@ -575,7 +575,7 @@ func TestRemoteDelivery_REQUIRETLS_Relaxed_NoTLS(t *testing.T) {
 	})
 	tgt.relaxedREQUIRETLS = true
 	tgt.tlsConfig = nil
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	if _, err := testutils.DoTestDeliveryErrMeta(t, tgt, "test@example.com", []string{"test@example.invalid"}, &module.MsgMetadata{
 		OriginalFrom: "test@example.com",
@@ -626,7 +626,7 @@ func TestRemoteDelivery_REQUIRETLS_Relaxed_TLSFail(t *testing.T) {
 	srv.TLSConfig.MinVersion = tls.VersionTLS11
 	srv.TLSConfig.MaxVersion = tls.VersionTLS11
 	tgt.tlsConfig = clientCfg
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	if _, err := testutils.DoTestDeliveryErrMeta(t, tgt, "test@example.com", []string{"test@example.invalid"}, &module.MsgMetadata{
 		OriginalFrom: "test@example.com",
diff --git a/internal/target/remote/policy_group.go b/internal/target/remote/policy_group.go
index 68a2202b..992bcfa4 100644
--- a/internal/target/remote/policy_group.go
+++ b/internal/target/remote/policy_group.go
@@ -39,7 +39,7 @@ type PolicyGroup struct {
 	pols     map[string]module.MXAuthPolicy
 }
 
-func (pg *PolicyGroup) Init(cfg *config.Map) error {
+func (pg *PolicyGroup) Configure(inlineArgs []string, cfg *config.Map) error {
 	var debugLog bool
 	cfg.Bool("debug", true, false, &debugLog)
 	cfg.AllowUnknown()
@@ -87,16 +87,16 @@ func (pg *PolicyGroup) Init(cfg *config.Map) error {
 	return nil
 }
 
-func (PolicyGroup) Name() string {
+func (*PolicyGroup) Name() string {
 	return "mx_auth"
 }
 
-func (pg PolicyGroup) InstanceName() string {
+func (pg *PolicyGroup) InstanceName() string {
 	return pg.instName
 }
 
 func init() {
-	module.Register("mx_auth", func(_, instName string, _, _ []string) (module.Module, error) {
+	module.Register("mx_auth", func(_, instName string) (module.Module, error) {
 		return &PolicyGroup{
 			instName: instName,
 			pols:     map[string]module.MXAuthPolicy{},
diff --git a/internal/target/remote/remote.go b/internal/target/remote/remote.go
index d4c42ed6..79b683fd 100644
--- a/internal/target/remote/remote.go
+++ b/internal/target/remote/remote.go
@@ -87,10 +87,7 @@ type Target struct {
 
 var _ module.DeliveryTarget = &Target{}
 
-func New(_, instName string, _, inlineArgs []string) (module.Module, error) {
-	if len(inlineArgs) != 0 {
-		return nil, errors.New("remote: inline arguments are not used")
-	}
+func New(_, instName string) (module.Module, error) {
 	// Keep this synchronized with testTarget.
 	return &Target{
 		name:     instName,
@@ -100,7 +97,11 @@ func New(_, instName string, _, inlineArgs []string) (module.Module, error) {
 	}, nil
 }
 
-func (rt *Target) Init(cfg *config.Map) error {
+func (rt *Target) Configure(inlineArgs []string, cfg *config.Map) error {
+	if len(inlineArgs) != 0 {
+		return errors.New("remote: inline arguments are not used")
+	}
+
 	var err error
 	rt.extResolver, err = dns.NewExtResolver()
 	if err != nil {
@@ -184,7 +185,11 @@ func (rt *Target) Init(cfg *config.Map) error {
 	return nil
 }
 
-func (rt *Target) Close() error {
+func (rt *Target) Start() error {
+	return nil
+}
+
+func (rt *Target) Stop() error {
 	rt.pool.Close()
 
 	return nil
@@ -210,11 +215,11 @@ type remoteDelivery struct {
 	policies []module.DeliveryMXAuthPolicy
 }
 
-func (rt *Target) Start(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) {
+func (rt *Target) StartDelivery(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) {
 	policies := make([]module.DeliveryMXAuthPolicy, 0, len(rt.policies))
 	if !(msgMeta.TLSRequireOverride && rt.allowSecOverride) {
 		for _, p := range rt.policies {
-			policies = append(policies, p.Start(msgMeta))
+			policies = append(policies, p.StartDelivery(msgMeta))
 		}
 	}
 
diff --git a/internal/target/remote/remote_test.go b/internal/target/remote/remote_test.go
index 4998e0c6..99b2492a 100644
--- a/internal/target/remote/remote_test.go
+++ b/internal/target/remote/remote_test.go
@@ -72,12 +72,12 @@ func testTarget(t *testing.T, zones map[string]mockdns.Zone, extResolver *dns.Ex
 }
 
 func testSTSPolicy(t *testing.T, zones map[string]mockdns.Zone, mtastsGet func(context.Context, string) (*mtasts.Policy, error)) *mtastsPolicy {
-	m, err := NewMTASTSPolicy("mx_auth.mtasts", "test", nil, nil)
+	m, err := NewMTASTSPolicy("mx_auth.mtasts", "test")
 	if err != nil {
 		t.Fatal(err)
 	}
 	p := m.(*mtastsPolicy)
-	err = p.Init(config.NewMap(nil, config.Node{
+	err = p.Configure(nil, config.NewMap(nil, config.Node{
 		Children: []config.Node{
 			{
 				Name: "cache",
@@ -98,12 +98,12 @@ func testSTSPolicy(t *testing.T, zones map[string]mockdns.Zone, mtastsGet func(c
 }
 
 func testDANEPolicy(t *testing.T, extR *dns.ExtResolver) *danePolicy {
-	m, err := NewDANEPolicy("mx_auth.dane", "test", nil, nil)
+	m, err := NewDANEPolicy("mx_auth.dane", "test")
 	if err != nil {
 		t.Fatal(err)
 	}
 	p := m.(*danePolicy)
-	err = p.Init(config.NewMap(nil, config.Node{
+	err = p.Configure(nil, config.NewMap(nil, config.Node{
 		Children: nil,
 	}))
 	if err != nil {
@@ -129,7 +129,7 @@ func TestRemoteDelivery(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 	testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"})
 
 	be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"})
@@ -146,9 +146,9 @@ func TestRemoteDelivery_NoMXFallback(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 
-	delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
+	delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -176,7 +176,7 @@ func TestRemoteDelivery_EmptySender(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 	testutils.DoTestDelivery(t, tgt, "", []string{"test@example.invalid"})
 
 	be.CheckMsg(t, 0, "", []string{"test@example.invalid"})
@@ -202,7 +202,7 @@ func TestRemoteDelivery_IPLiteral(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 	testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@[127.0.0.1]"})
 
 	be.CheckMsg(t, 0, "test@example.com", []string{"test@[127.0.0.1]"})
@@ -219,7 +219,7 @@ func TestRemoteDelivery_FallbackMX(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"})
 	be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"})
@@ -239,7 +239,7 @@ func TestRemoteDelivery_BodyNonAtomic(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	c := multipleErrs{
 		errs: map[string]error{},
@@ -267,9 +267,9 @@ func TestRemoteDelivery_Abort(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 
-	delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
+	delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -297,9 +297,9 @@ func TestRemoteDelivery_CommitWithoutBody(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 
-	delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
+	delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -334,9 +334,9 @@ func TestRemoteDelivery_MAILFROMErr(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 
-	delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
+	delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -360,9 +360,9 @@ func TestRemoteDelivery_NoMX(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 
-	delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
+	delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -390,9 +390,9 @@ func TestRemoteDelivery_NullMX(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 
-	delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
+	delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -419,11 +419,11 @@ func TestRemoteDelivery_Quarantined(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	meta := module.MsgMetadata{ID: "test..."}
 
-	delivery, err := tgt.Start(context.Background(), &meta, "test@example.com")
+	delivery, err := tgt.StartDelivery(context.Background(), &meta, "test@example.com")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -467,9 +467,9 @@ func TestRemoteDelivery_MAILFROMErr_Repeated(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 
-	delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
+	delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -507,9 +507,9 @@ func TestRemoteDelivery_RcptErr(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 
-	delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
+	delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -558,7 +558,7 @@ func TestRemoteDelivery_DownMX(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"})
 	be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"})
@@ -581,7 +581,7 @@ func TestRemoteDelivery_AllMXDown(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	_, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"})
 	if err == nil {
@@ -612,7 +612,7 @@ func TestRemoteDelivery_Split(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid", "test@example2.invalid"})
 
@@ -651,9 +651,9 @@ func TestRemoteDelivery_Split_Fail(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 
-	delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
+	delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -704,9 +704,9 @@ func TestRemoteDelivery_BodyErr(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 
-	delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
+	delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -758,9 +758,9 @@ func TestRemoteDelivery_Split_BodyErr(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 
-	delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
+	delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -814,9 +814,9 @@ func TestRemoteDelivery_Split_BodyErr_NonAtomic(t *testing.T) {
 	}
 
 	tgt := testTarget(t, zones, nil, nil)
-	defer tgt.Close()
+	defer tgt.Stop()
 
-	delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
+	delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -874,7 +874,7 @@ func TestRemoteDelivery_TLSErrFallback(t *testing.T) {
 
 	tgt := testTarget(t, zones, nil, nil)
 	tgt.tlsConfig = clientCfg
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"})
 	be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"})
@@ -896,7 +896,7 @@ func TestRemoteDelivery_RequireTLS_Missing(t *testing.T) {
 	tgt := testTarget(t, zones, nil, []module.MXAuthPolicy{
 		&localPolicy{minTLSLevel: module.TLSEncrypted},
 	})
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	_, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"})
 	if err == nil {
@@ -921,7 +921,7 @@ func TestRemoteDelivery_RequireTLS_Present(t *testing.T) {
 		&localPolicy{minTLSLevel: module.TLSEncrypted},
 	})
 	tgt.tlsConfig = clientCfg
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"})
 	be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"})
@@ -950,7 +950,7 @@ func TestRemoteDelivery_RequireTLS_NoErrFallback(t *testing.T) {
 		&localPolicy{minTLSLevel: module.TLSEncrypted},
 	})
 	tgt.tlsConfig = clientCfg
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	_, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"})
 	if err == nil {
@@ -975,7 +975,7 @@ func TestRemoteDelivery_TLS_FallbackNoVerify(t *testing.T) {
 	tgt := testTarget(t, zones, nil, []module.MXAuthPolicy{
 		&localPolicy{minTLSLevel: module.TLSEncrypted},
 	})
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"})
 	be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"})
@@ -1008,7 +1008,7 @@ func TestRemoteDelivery_TLS_FallbackPlaintext(t *testing.T) {
 
 	tgt := testTarget(t, zones, nil, nil)
 	tgt.tlsConfig = clientCfg
-	defer tgt.Close()
+	defer tgt.Stop()
 
 	testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"})
 	be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"})
@@ -1041,7 +1041,7 @@ func TestRemoteDelivery_ConnReuse(t *testing.T) {
 
 	tgt := testTarget(t, zones, nil, nil)
 	tgt.connReuseLimit = 5
-	defer tgt.Close()
+	defer tgt.Stop()
 	testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"})
 	testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"})
 
diff --git a/internal/target/remote/security.go b/internal/target/remote/security.go
index a8177fb1..51572b89 100644
--- a/internal/target/remote/security.go
+++ b/internal/target/remote/security.go
@@ -52,7 +52,7 @@ type (
 	}
 )
 
-func NewMTASTSPolicy(_, instName string, _, _ []string) (module.Module, error) {
+func NewMTASTSPolicy(_, instName string) (module.Module, error) {
 	return &mtastsPolicy{
 		instName: instName,
 		log:      log.Logger{Name: "mx_auth.mtasts", Debug: log.DefaultLogger.Debug},
@@ -71,7 +71,7 @@ func (c *mtastsPolicy) Weight() int {
 	return 10
 }
 
-func (c *mtastsPolicy) Init(cfg *config.Map) error {
+func (c *mtastsPolicy) Configure(inlineArgs []string, cfg *config.Map) error {
 	var (
 		storeType string
 		storeDir  string
@@ -99,6 +99,11 @@ func (c *mtastsPolicy) Init(cfg *config.Map) error {
 	return nil
 }
 
+func (c *mtastsPolicy) Start() error {
+	c.StartUpdater()
+	return nil
+}
+
 // StartUpdater starts a goroutine to update MTA-STS cache periodically until
 // Close is called.
 //
@@ -108,6 +113,15 @@ func (c *mtastsPolicy) StartUpdater() {
 	go c.updater()
 }
 
+func (c *mtastsPolicy) Stop() error {
+	if c.updaterStop != nil {
+		c.updaterStop <- struct{}{}
+		<-c.updaterStop
+		c.updaterStop = nil
+	}
+	return nil
+}
+
 func (c *mtastsPolicy) updater() {
 	defer func() {
 		if err := recover(); err != nil {
@@ -142,22 +156,13 @@ func (c *mtastsPolicy) updater() {
 	}
 }
 
-func (c *mtastsPolicy) Start(msgMeta *module.MsgMetadata) module.DeliveryMXAuthPolicy {
+func (c *mtastsPolicy) StartDelivery(msgMeta *module.MsgMetadata) module.DeliveryMXAuthPolicy {
 	return &mtastsDelivery{
 		c:   c,
 		log: target.DeliveryLogger(c.log, msgMeta),
 	}
 }
 
-func (c *mtastsPolicy) Close() error {
-	if c.updaterStop != nil {
-		c.updaterStop <- struct{}{}
-		<-c.updaterStop
-		c.updaterStop = nil
-	}
-	return nil
-}
-
 func (c *mtastsDelivery) PrepareDomain(ctx context.Context, domain string) {
 	c.policyFut = future.New()
 	go func() {
@@ -237,7 +242,7 @@ type stsPreloadPolicy struct {
 	instName string
 }
 
-func NewSTSPreload(_, instName string, _, _ []string) (module.Module, error) {
+func NewSTSPreload(_, instName string) (module.Module, error) {
 	return &stsPreloadPolicy{
 		instName: instName,
 		log:      log.Logger{Name: "mx_auth.sts_preload", Debug: log.DefaultLogger.Debug},
@@ -256,7 +261,7 @@ func (c *stsPreloadPolicy) Weight() int {
 	return 30 // after MTA-STS
 }
 
-func (c *stsPreloadPolicy) Init(cfg *config.Map) error {
+func (c *stsPreloadPolicy) Configure(inlineArgs []string, cfg *config.Map) error {
 	c.log.Println("sts_preload module is deprecated and is no-op as the list is expired and unmaintained")
 
 	var (
@@ -276,7 +281,7 @@ type preloadDelivery struct {
 	*stsPreloadPolicy
 }
 
-func (p *stsPreloadPolicy) Start(*module.MsgMetadata) module.DeliveryMXAuthPolicy {
+func (p *stsPreloadPolicy) StartDelivery(*module.MsgMetadata) module.DeliveryMXAuthPolicy {
 	return &preloadDelivery{stsPreloadPolicy: p}
 }
 
@@ -291,15 +296,11 @@ func (p *preloadDelivery) CheckConn(ctx context.Context, mxLevel module.MXLevel,
 	return tlsLevel, nil
 }
 
-func (p *stsPreloadPolicy) Close() error {
-	return nil
-}
-
 type dnssecPolicy struct {
 	instName string
 }
 
-func NewDNSSECPolicy(_, instName string, _, _ []string) (module.Module, error) {
+func NewDNSSECPolicy(_, instName string) (module.Module, error) {
 	return &dnssecPolicy{
 		instName: instName,
 	}, nil
@@ -317,19 +318,15 @@ func (c *dnssecPolicy) Weight() int {
 	return 1
 }
 
-func (c *dnssecPolicy) Init(cfg *config.Map) error {
+func (c *dnssecPolicy) Configure(inlineArgs []string, cfg *config.Map) error {
 	_, err := cfg.Process() // will fail if there is any directive
 	return err
 }
 
-func (dnssecPolicy) Start(*module.MsgMetadata) module.DeliveryMXAuthPolicy {
+func (dnssecPolicy) StartDelivery(*module.MsgMetadata) module.DeliveryMXAuthPolicy {
 	return dnssecPolicy{}
 }
 
-func (dnssecPolicy) Close() error {
-	return nil
-}
-
 func (dnssecPolicy) Reset(*module.MsgMetadata)                        {}
 func (dnssecPolicy) PrepareDomain(ctx context.Context, domain string) {}
 func (dnssecPolicy) PrepareConn(ctx context.Context, mx string)       {}
@@ -357,7 +354,7 @@ type (
 	}
 )
 
-func NewDANEPolicy(_, instName string, _, _ []string) (module.Module, error) {
+func NewDANEPolicy(_, instName string) (module.Module, error) {
 	return &danePolicy{
 		instName: instName,
 		log:      log.Logger{Name: "remote/dane", Debug: log.DefaultLogger.Debug},
@@ -376,7 +373,7 @@ func (c *danePolicy) Weight() int {
 	return 10
 }
 
-func (c *danePolicy) Init(cfg *config.Map) error {
+func (c *danePolicy) Configure(inlineArgs []string, cfg *config.Map) error {
 	var err error
 	c.extResolver, err = dns.NewExtResolver()
 	if err != nil {
@@ -389,14 +386,10 @@ func (c *danePolicy) Init(cfg *config.Map) error {
 	return err
 }
 
-func (c *danePolicy) Start(*module.MsgMetadata) module.DeliveryMXAuthPolicy {
+func (c *danePolicy) StartDelivery(*module.MsgMetadata) module.DeliveryMXAuthPolicy {
 	return &daneDelivery{c: c}
 }
 
-func (c *danePolicy) Close() error {
-	return nil
-}
-
 func (c *daneDelivery) PrepareDomain(ctx context.Context, domain string) {}
 
 func (c *daneDelivery) discoverTLSA(ctx context.Context, mx string) ([]dns.TLSA, error) {
@@ -536,7 +529,7 @@ type (
 	}
 )
 
-func NewLocalPolicy(_, instName string, _, _ []string) (module.Module, error) {
+func NewLocalPolicy(_, instName string) (module.Module, error) {
 	return &localPolicy{
 		instName: instName,
 	}, nil
@@ -554,7 +547,7 @@ func (c *localPolicy) Weight() int {
 	return 1000
 }
 
-func (c *localPolicy) Init(cfg *config.Map) error {
+func (c *localPolicy) Configure(inlineArgs []string, cfg *config.Map) error {
 	var (
 		minTLSLevel string
 		minMXLevel  string
@@ -589,19 +582,15 @@ func (c *localPolicy) Init(cfg *config.Map) error {
 	return nil
 }
 
-func (l localPolicy) Start(msgMeta *module.MsgMetadata) module.DeliveryMXAuthPolicy {
+func (l *localPolicy) StartDelivery(msgMeta *module.MsgMetadata) module.DeliveryMXAuthPolicy {
 	return l
 }
 
-func (l localPolicy) Close() error {
-	return nil
-}
-
-func (l localPolicy) Reset(*module.MsgMetadata)                        {}
-func (l localPolicy) PrepareDomain(ctx context.Context, domain string) {}
-func (l localPolicy) PrepareConn(ctx context.Context, mx string)       {}
+func (l *localPolicy) Reset(*module.MsgMetadata)                        {}
+func (l *localPolicy) PrepareDomain(ctx context.Context, domain string) {}
+func (l *localPolicy) PrepareConn(ctx context.Context, mx string)       {}
 
-func (l localPolicy) CheckMX(ctx context.Context, mxLevel module.MXLevel, domain, mx string, dnssec bool) (module.MXLevel, error) {
+func (l *localPolicy) CheckMX(ctx context.Context, mxLevel module.MXLevel, domain, mx string, dnssec bool) (module.MXLevel, error) {
 	if mxLevel < l.minMXLevel {
 		return module.MXNone, &exterrors.SMTPError{
 			// Err on the side of caution if policy evaluation was messed up by
@@ -618,7 +607,7 @@ func (l localPolicy) CheckMX(ctx context.Context, mxLevel module.MXLevel, domain
 	return module.MXNone, nil
 }
 
-func (l localPolicy) CheckConn(ctx context.Context, mxLevel module.MXLevel, tlsLevel module.TLSLevel, domain, mx string, tlsState tls.ConnectionState) (module.TLSLevel, error) {
+func (l *localPolicy) CheckConn(ctx context.Context, mxLevel module.MXLevel, tlsLevel module.TLSLevel, domain, mx string, tlsState tls.ConnectionState) (module.TLSLevel, error) {
 	if tlsLevel < l.minTLSLevel {
 		return module.TLSNone, &exterrors.SMTPError{
 			Code:         451,
diff --git a/internal/target/smtp/smtp_downstream.go b/internal/target/smtp/smtp_downstream.go
index 8880f6cb..8c50b71b 100644
--- a/internal/target/smtp/smtp_downstream.go
+++ b/internal/target/smtp/smtp_downstream.go
@@ -48,10 +48,9 @@ import (
 )
 
 type Downstream struct {
-	modName    string
-	instName   string
-	lmtp       bool
-	targetsArg []string
+	modName  string
+	instName string
+	lmtp     bool
 
 	starttls    bool
 	hostname    string
@@ -76,17 +75,16 @@ func (u *Downstream) moduleError(err error) error {
 	})
 }
 
-func NewDownstream(modName, instName string, _, inlineArgs []string) (module.Module, error) {
+func NewDownstream(modName, instName string) (module.Module, error) {
 	return &Downstream{
-		modName:    modName,
-		instName:   instName,
-		lmtp:       modName == "target.lmtp" || modName == "lmtp_downstream", /* compatibility with 0.3 configs */
-		targetsArg: inlineArgs,
-		log:        log.Logger{Name: modName},
+		modName:  modName,
+		instName: instName,
+		lmtp:     modName == "target.lmtp" || modName == "lmtp_downstream", /* compatibility with 0.3 configs */
+		log:      log.Logger{Name: modName},
 	}, nil
 }
 
-func (u *Downstream) Init(cfg *config.Map) error {
+func (u *Downstream) Configure(inlineArgs []string, cfg *config.Map) error {
 	var attemptTLS *bool
 
 	var targetsArg []string
@@ -142,8 +140,8 @@ func (u *Downstream) Init(cfg *config.Map) error {
 		return fmt.Errorf("%s: cannot represent the hostname as an A-label name: %w", u.modName, err)
 	}
 
-	u.targetsArg = append(u.targetsArg, targetsArg...)
-	for _, tgt := range u.targetsArg {
+	targetsArg = append(targetsArg, inlineArgs...)
+	for _, tgt := range targetsArg {
 		endp, err := config.ParseEndpoint(tgt)
 		if err != nil {
 			return err
@@ -183,8 +181,8 @@ type lmtpDelivery struct {
 	*delivery
 }
 
-func (u *Downstream) Start(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) {
-	defer trace.StartRegion(ctx, "target.smtp/Start").End()
+func (u *Downstream) StartDelivery(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) {
+	defer trace.StartRegion(ctx, "target.smtp/StartDelivery").End()
 
 	d := &delivery{
 		u:        u,
diff --git a/internal/target/smtp/smtputf8_test.go b/internal/target/smtp/smtputf8_test.go
index 74aae232..f47fc778 100644
--- a/internal/target/smtp/smtputf8_test.go
+++ b/internal/target/smtp/smtputf8_test.go
@@ -30,11 +30,11 @@ func TestDownstreamDelivery_EHLO_ALabel(t *testing.T) {
 	defer srv.Close()
 	defer testutils.CheckSMTPConnLeak(t, srv)
 
-	mod, err := NewDownstream("", "", nil, []string{"tcp://127.0.0.1:" + testPort})
+	mod, err := NewDownstream("", "")
 	if err != nil {
 		t.Fatal(err)
 	}
-	if err := mod.Init(config.NewMap(nil, config.Node{
+	if err := mod.Configure([]string{"tcp://127.0.0.1:" + testPort}, config.NewMap(nil, config.Node{
 		Children: []config.Node{
 			{
 				Name: "hostname",
diff --git a/internal/testutils/bench_delivery.go b/internal/testutils/bench_delivery.go
index f434efc0..eedd0d2a 100644
--- a/internal/testutils/bench_delivery.go
+++ b/internal/testutils/bench_delivery.go
@@ -117,7 +117,7 @@ func BenchDelivery(b *testing.B, target module.DeliveryTarget, sender string, re
 	b.ReportAllocs()
 	b.ResetTimer()
 	for i := 0; i < b.N; i++ {
-		delivery, err := target.Start(benchCtx, &meta, sender)
+		delivery, err := target.StartDelivery(benchCtx, &meta, sender)
 		if err != nil {
 			b.Fatal(err)
 		}
diff --git a/internal/testutils/check.go b/internal/testutils/check.go
index 399a78d4..2c44883d 100644
--- a/internal/testutils/check.go
+++ b/internal/testutils/check.go
@@ -54,7 +54,7 @@ func (c *Check) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadat
 	return &checkState{msgMeta, c}, nil
 }
 
-func (c *Check) Init(*config.Map) error {
+func (c *Check) Configure([]string, *config.Map) error {
 	return nil
 }
 
@@ -104,8 +104,7 @@ func (cs *checkState) Close() error {
 }
 
 func init() {
-	module.Register("test_check", func(_, _ string, _, _ []string) (module.Module, error) {
+	module.Register("test_check", func(_, _ string) (module.Module, error) {
 		return &Check{}, nil
 	})
-	module.RegisterInstance(&Check{}, nil)
 }
diff --git a/internal/testutils/modifier.go b/internal/testutils/modifier.go
index a96cbe48..1c6f135a 100644
--- a/internal/testutils/modifier.go
+++ b/internal/testutils/modifier.go
@@ -42,7 +42,7 @@ type Modifier struct {
 	UnclosedStates int
 }
 
-func (m Modifier) Init(*config.Map) error {
+func (m Modifier) Configure([]string, *config.Map) error {
 	return nil
 }
 
@@ -115,8 +115,7 @@ func (ms modifierState) Close() error {
 }
 
 func init() {
-	module.Register("test_modifier", func(_, _ string, _, _ []string) (module.Module, error) {
+	module.Register("test_modifier", func(_, _ string) (module.Module, error) {
 		return &Modifier{}, nil
 	})
-	module.RegisterInstance(&Modifier{}, nil)
 }
diff --git a/internal/testutils/target.go b/internal/testutils/target.go
index 68f5b394..19585215 100644
--- a/internal/testutils/target.go
+++ b/internal/testutils/target.go
@@ -62,7 +62,7 @@ type Target struct {
 module.Module is implemented with dummy functions for logging done by MsgPipeline code.
 */
 
-func (dt Target) Init(*config.Map) error {
+func (dt Target) Configure([]string, *config.Map) error {
 	return nil
 }
 
@@ -86,7 +86,7 @@ type testTargetDeliveryPartial struct {
 	testTargetDelivery
 }
 
-func (dt *Target) Start(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) {
+func (dt *Target) StartDelivery(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) {
 	if dt.PartialBodyErr != nil {
 		return &testTargetDeliveryPartial{
 			testTargetDelivery: testTargetDelivery{
@@ -211,10 +211,10 @@ func DoTestDeliveryNonAtomic(t *testing.T, c module.StatusCollector, tgt module.
 		ID:              encodedID,
 		OriginalFrom:    from,
 	}
-	t.Log("-- tgt.Start", from)
-	delivery, err := tgt.Start(testCtx, &msgMeta, from)
+	t.Log("-- tgt.StartDelivery", from)
+	delivery, err := tgt.StartDelivery(testCtx, &msgMeta, from)
 	if err != nil {
-		t.Log("-- ... tgt.Start", from, err, exterrors.Fields(err))
+		t.Log("-- ... tgt.StartDelivery", from, err, exterrors.Fields(err))
 		t.Fatalf("Unexpected err: %v %+v", err, exterrors.Fields(err))
 		return encodedID
 	}
@@ -262,10 +262,10 @@ func DoTestDeliveryErrMeta(t *testing.T, tgt module.DeliveryTarget, from string,
 	body := buffer.MemoryBuffer{Slice: []byte("foobar\r\n")}
 	msgMeta.DontTraceSender = true
 	msgMeta.ID = encodedID
-	t.Log("-- tgt.Start", from)
-	delivery, err := tgt.Start(testCtx, msgMeta, from)
+	t.Log("-- tgt.StartDelivery", from)
+	delivery, err := tgt.StartDelivery(testCtx, msgMeta, from)
 	if err != nil {
-		t.Log("-- ... tgt.Start", from, err, exterrors.Fields(err))
+		t.Log("-- ... tgt.StartDelivery", from, err, exterrors.Fields(err))
 		return encodedID, err
 	}
 	for _, rcpt := range to {
diff --git a/internal/tls/acme/acme.go b/internal/tls/acme/acme.go
index a09c3e09..0ebcd241 100644
--- a/internal/tls/acme/acme.go
+++ b/internal/tls/acme/acme.go
@@ -19,6 +19,7 @@ const modName = "tls.loader.acme"
 type Loader struct {
 	instName string
 
+	names        []string
 	store        certmagic.Storage
 	cache        *certmagic.Cache
 	cfg          *certmagic.Config
@@ -27,17 +28,18 @@ type Loader struct {
 	log log.Logger
 }
 
-func New(_, instName string, _, inlineArgs []string) (module.Module, error) {
-	if len(inlineArgs) != 0 {
-		return nil, fmt.Errorf("%s: no inline args expected", modName)
-	}
+func New(_, instName string) (module.Module, error) {
 	return &Loader{
 		instName: instName,
 		log:      log.Logger{Name: modName},
 	}, nil
 }
 
-func (l *Loader) Init(cfg *config.Map) error {
+func (l *Loader) Configure(inlineArgs []string, cfg *config.Map) error {
+	if len(inlineArgs) != 0 {
+		return fmt.Errorf("%s: no inline args expected", modName)
+	}
+
 	var (
 		hostname       string
 		extraNames     []string
@@ -118,27 +120,28 @@ func (l *Loader) Init(cfg *config.Map) error {
 	}
 	l.cfg.Issuers = []certmagic.Issuer{issuer}
 
-	if module.NoRun {
-		return nil
-	}
+	l.names = append([]string{hostname}, extraNames...)
+
+	return nil
+}
+
+func (l *Loader) ConfigureTLS(c *tls.Config) error {
+	c.GetCertificate = l.cfg.GetCertificate
+	return nil
+}
 
+func (l *Loader) Start() error {
 	manageCtx, cancelManage := context.WithCancel(context.Background())
-	err := l.cfg.ManageAsync(manageCtx, append([]string{hostname}, extraNames...))
+	err := l.cfg.ManageAsync(manageCtx, l.names)
 	if err != nil {
 		cancelManage()
 		return err
 	}
 	l.cancelManage = cancelManage
-
-	return nil
-}
-
-func (l *Loader) ConfigureTLS(c *tls.Config) error {
-	c.GetCertificate = l.cfg.GetCertificate
 	return nil
 }
 
-func (l *Loader) Close() error {
+func (l *Loader) Stop() error {
 	l.cancelManage()
 	l.cache.Stop()
 	return nil
diff --git a/internal/tls/file.go b/internal/tls/file.go
index 943a59ed..ae260ec6 100644
--- a/internal/tls/file.go
+++ b/internal/tls/file.go
@@ -27,17 +27,15 @@ import (
 	"time"
 
 	"github.com/foxcpp/maddy/framework/config"
-	"github.com/foxcpp/maddy/framework/hooks"
 	"github.com/foxcpp/maddy/framework/log"
 	"github.com/foxcpp/maddy/framework/module"
 )
 
 type FileLoader struct {
-	instName   string
-	inlineArgs []string
-	certPaths  []string
-	keyPaths   []string
-	log        log.Logger
+	instName  string
+	certPaths []string
+	keyPaths  []string
+	log       log.Logger
 
 	certs     []tls.Certificate
 	certsLock sync.RWMutex
@@ -46,16 +44,15 @@ type FileLoader struct {
 	stopTick   chan struct{}
 }
 
-func NewFileLoader(_, instName string, _, inlineArgs []string) (module.Module, error) {
+func NewFileLoader(_, instName string) (module.Module, error) {
 	return &FileLoader{
-		instName:   instName,
-		inlineArgs: inlineArgs,
-		log:        log.Logger{Name: "tls.loader.file", Debug: log.DefaultLogger.Debug},
-		stopTick:   make(chan struct{}),
+		instName: instName,
+		log:      log.Logger{Name: "tls.loader.file", Debug: log.DefaultLogger.Debug},
+		stopTick: make(chan struct{}),
 	}, nil
 }
 
-func (f *FileLoader) Init(cfg *config.Map) error {
+func (f *FileLoader) Configure(inlineArgs []string, cfg *config.Map) error {
 	cfg.StringList("certs", false, false, nil, &f.certPaths)
 	cfg.StringList("keys", false, false, nil, &f.keyPaths)
 	if _, err := cfg.Process(); err != nil {
@@ -66,12 +63,12 @@ func (f *FileLoader) Init(cfg *config.Map) error {
 		return errors.New("tls.loader.file: mismatch in certs and keys count")
 	}
 
-	if len(f.inlineArgs)%2 != 0 {
+	if len(inlineArgs)%2 != 0 {
 		return errors.New("tls.loader.file: odd amount of arguments")
 	}
-	for i := 0; i < len(f.inlineArgs); i += 2 {
-		f.certPaths = append(f.certPaths, f.inlineArgs[i])
-		f.keyPaths = append(f.keyPaths, f.inlineArgs[i+1])
+	for i := 0; i < len(inlineArgs); i += 2 {
+		f.certPaths = append(f.certPaths, inlineArgs[i])
+		f.keyPaths = append(f.keyPaths, inlineArgs[i+1])
 	}
 
 	for _, certPath := range f.certPaths {
@@ -84,19 +81,21 @@ func (f *FileLoader) Init(cfg *config.Map) error {
 		return err
 	}
 
-	hooks.AddHook(hooks.EventReload, func() {
-		f.log.Println("reloading certificates")
-		if err := f.loadCerts(); err != nil {
-			f.log.Error("reload failed", err)
-		}
-	})
+	return nil
+}
 
+func (f *FileLoader) Start() error {
 	f.reloadTick = time.NewTicker(time.Minute)
 	go f.reloadTicker()
 	return nil
 }
 
-func (f *FileLoader) Close() error {
+func (f *FileLoader) Reload() error {
+	f.log.Println("reloading certificates")
+	return f.loadCerts()
+}
+
+func (f *FileLoader) Stop() error {
 	f.reloadTick.Stop()
 	f.stopTick <- struct{}{}
 	return nil
diff --git a/internal/tls/self_signed.go b/internal/tls/self_signed.go
index d5e174f3..269b0c0d 100644
--- a/internal/tls/self_signed.go
+++ b/internal/tls/self_signed.go
@@ -40,14 +40,14 @@ type SelfSignedLoader struct {
 	cert tls.Certificate
 }
 
-func NewSelfSignedLoader(_, instName string, _, inlineArgs []string) (module.Module, error) {
+func NewSelfSignedLoader(_, instName string) (module.Module, error) {
 	return &SelfSignedLoader{
-		instName:    instName,
-		serverNames: inlineArgs,
+		instName: instName,
 	}, nil
 }
 
-func (f *SelfSignedLoader) Init(cfg *config.Map) error {
+func (f *SelfSignedLoader) Configure(inlineArgs []string, cfg *config.Map) error {
+	f.serverNames = inlineArgs
 	if _, err := cfg.Process(); err != nil {
 		return err
 	}
diff --git a/maddy.go b/maddy.go
index e6a05cf1..4b7204e1 100644
--- a/maddy.go
+++ b/maddy.go
@@ -21,7 +21,6 @@ package maddy
 import (
 	"errors"
 	"fmt"
-	"io"
 	"net/http"
 	"os"
 	"path/filepath"
@@ -33,6 +32,7 @@ import (
 	"github.com/foxcpp/maddy/framework/config"
 	modconfig "github.com/foxcpp/maddy/framework/config/module"
 	"github.com/foxcpp/maddy/framework/config/tls"
+	"github.com/foxcpp/maddy/framework/container"
 	"github.com/foxcpp/maddy/framework/hooks"
 	"github.com/foxcpp/maddy/framework/log"
 	"github.com/foxcpp/maddy/framework/module"
@@ -110,6 +110,18 @@ func init() {
 			Value:   filepath.Join(ConfigDirectory, "maddy.conf"),
 		},
 	)
+	maddycli.AddSubcommand(&cli.Command{
+		Name:  "verify-config",
+		Usage: "Check configuration file for errors",
+		Flags: []cli.Flag{
+			&cli.BoolFlag{
+				Name:        "debug",
+				Usage:       "enable debug logging early",
+				Destination: &log.DefaultLogger.Debug,
+			},
+		},
+		Action: VerifyConfig,
+	})
 	maddycli.AddSubcommand(&cli.Command{
 		Name:  "run",
 		Usage: "Start the server",
@@ -189,26 +201,27 @@ func Run(c *cli.Context) error {
 
 	os.Setenv("PATH", config.LibexecDirectory+string(filepath.ListSeparator)+os.Getenv("PATH"))
 
-	f, err := os.Open(c.Path("config"))
-	if err != nil {
-		systemdStatusErr(err)
-		return cli.Exit(err.Error(), 2)
-	}
-	defer f.Close()
+	hooks.AddHook(hooks.EventLogRotate, reinitLogging)
+	defer log.DefaultLogger.Out.Close()
+	defer hooks.RunHooks(hooks.EventShutdown)
 
-	cfg, err := parser.Read(f, c.Path("config"))
-	if err != nil {
+	if err := moduleMain(c.Path("config")); err != nil {
 		systemdStatusErr(err)
-		return cli.Exit(err.Error(), 2)
+		return cli.Exit(err.Error(), 1)
 	}
 
-	defer log.DefaultLogger.Out.Close()
+	return nil
+}
 
-	if err := moduleMain(cfg); err != nil {
-		systemdStatusErr(err)
-		return cli.Exit(err.Error(), 1)
+func VerifyConfig(c *cli.Context) error {
+	os.Setenv("PATH", config.LibexecDirectory+string(filepath.ListSeparator)+os.Getenv("PATH"))
+
+	if _, err := moduleConfigure(c.Path("config")); err != nil {
+		return cli.Exit(err.Error(), 2)
 	}
 
+	log.DefaultLogger.Msg("No errors detected")
+
 	return nil
 }
 
@@ -235,42 +248,46 @@ func initDebug(c *cli.Context) {
 	}
 }
 
-func InitDirs() error {
-	if config.StateDirectory == "" {
-		config.StateDirectory = DefaultStateDirectory
+func InitDirs(c *container.C) error {
+	if c.Config.StateDirectory == "" {
+		c.Config.StateDirectory = DefaultStateDirectory
 	}
-	if config.RuntimeDirectory == "" {
-		config.RuntimeDirectory = DefaultRuntimeDirectory
+	if c.Config.RuntimeDirectory == "" {
+		c.Config.RuntimeDirectory = DefaultRuntimeDirectory
 	}
-	if config.LibexecDirectory == "" {
-		config.LibexecDirectory = DefaultLibexecDirectory
+	if c.Config.LibexecDirectory == "" {
+		c.Config.LibexecDirectory = DefaultLibexecDirectory
 	}
 
-	if err := ensureDirectoryWritable(config.StateDirectory); err != nil {
+	if err := ensureDirectoryWritable(c.Config.StateDirectory); err != nil {
 		return err
 	}
-	if err := ensureDirectoryWritable(config.RuntimeDirectory); err != nil {
+	if err := ensureDirectoryWritable(c.Config.RuntimeDirectory); err != nil {
 		return err
 	}
 
 	// Make sure all paths we are going to use are absolute
 	// before we change the working directory.
-	if !filepath.IsAbs(config.StateDirectory) {
+	if !filepath.IsAbs(c.Config.StateDirectory) {
 		return errors.New("statedir should be absolute")
 	}
-	if !filepath.IsAbs(config.RuntimeDirectory) {
+	if !filepath.IsAbs(c.Config.RuntimeDirectory) {
 		return errors.New("runtimedir should be absolute")
 	}
-	if !filepath.IsAbs(config.LibexecDirectory) {
+	if !filepath.IsAbs(c.Config.LibexecDirectory) {
 		return errors.New("-libexec should be absolute")
 	}
 
 	// Change the working directory to make all relative paths
 	// in configuration relative to state directory.
-	if err := os.Chdir(config.StateDirectory); err != nil {
+	if err := os.Chdir(c.Config.StateDirectory); err != nil {
 		log.Println(err)
 	}
 
+	config.StateDirectory = c.Config.StateDirectory
+	config.RuntimeDirectory = c.Config.RuntimeDirectory
+	config.LibexecDirectory = c.Config.LibexecDirectory
+
 	return nil
 }
 
@@ -287,10 +304,10 @@ func ensureDirectoryWritable(path string) error {
 	return os.Remove(testFile.Name())
 }
 
-func ReadGlobals(cfg []config.Node) (map[string]interface{}, []config.Node, error) {
+func ReadGlobals(c *container.C, cfg []config.Node) (map[string]interface{}, []config.Node, error) {
 	globals := config.NewMap(nil, config.Node{Children: cfg})
-	globals.String("state_dir", false, false, DefaultStateDirectory, &config.StateDirectory)
-	globals.String("runtime_dir", false, false, DefaultRuntimeDirectory, &config.RuntimeDirectory)
+	globals.String("state_dir", false, false, DefaultStateDirectory, &c.Config.StateDirectory)
+	globals.String("runtime_dir", false, false, DefaultRuntimeDirectory, &c.Config.RuntimeDirectory)
 	globals.String("hostname", false, false, "", nil)
 	globals.String("autogenerated_msg_domain", false, false, "", nil)
 	globals.Custom("tls", false, false, nil, tls.TLSDirective, nil)
@@ -298,8 +315,8 @@ func ReadGlobals(cfg []config.Node) (map[string]interface{}, []config.Node, erro
 	globals.Bool("storage_perdomain", false, false, nil)
 	globals.Bool("auth_perdomain", false, false, nil)
 	globals.StringList("auth_domains", false, false, nil, nil)
-	globals.Custom("log", false, false, defaultLogOutput, logOutput, &log.DefaultLogger.Out)
-	globals.Bool("debug", false, log.DefaultLogger.Debug, &log.DefaultLogger.Debug)
+	globals.Custom("log", false, false, defaultLogOutput, logOutput, &c.DefaultLogger.Out)
+	globals.Bool("debug", false, log.DefaultLogger.Debug, &c.DefaultLogger.Debug)
 	config.EnumMapped(globals, "auth_map_normalize", true, false, authz.NormalizeFuncs, authz.NormalizeAuto, nil)
 	modconfig.Table(globals, "auth_map", true, false, nil, nil)
 	globals.AllowUnknown()
@@ -307,46 +324,86 @@ func ReadGlobals(cfg []config.Node) (map[string]interface{}, []config.Node, erro
 	return globals.Values, unknown, err
 }
 
-func moduleMain(cfg []config.Node) error {
-	globals, modBlocks, err := ReadGlobals(cfg)
+func ReadConfig(path string) ([]config.Node, error) {
+	f, err := os.Open(path)
 	if err != nil {
-		return err
+		return nil, err
+	}
+	defer f.Close()
+
+	return parser.Read(f, path)
+}
+
+func moduleConfigure(configPath string) (*container.C, error) {
+	c := container.New()
+	container.Global = c
+
+	cfg, err := ReadConfig(configPath)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read config %s: %w", configPath, err)
 	}
 
-	if err := InitDirs(); err != nil {
-		return err
+	globals, modBlocks, err := ReadGlobals(c, cfg)
+	if err != nil {
+		return nil, err
 	}
 
-	hooks.AddHook(hooks.EventLogRotate, reinitLogging)
+	if err := InitDirs(c); err != nil {
+		return nil, err
+	}
 
-	endpoints, mods, err := RegisterModules(globals, modBlocks)
+	err = RegisterModules(c, globals, modBlocks)
 	if err != nil {
-		return err
+		return nil, err
+	}
+
+	for _, inst := range c.Modules.NotInitialized() {
+		return nil, fmt.Errorf("unused configuration block %s (%s)",
+			inst.InstanceName(), inst.Name())
 	}
 
-	err = initModules(globals, endpoints, mods)
+	return c, nil
+}
+
+func moduleStart(c *container.C) error {
+	return c.Lifetime.StartAll()
+}
+
+func moduleStop(c *container.C) {
+	c.Lifetime.StopAll()
+}
+
+func moduleMain(configPath string) error {
+	log.DefaultLogger.Msg("loading configuration...")
+
+	c, err := moduleConfigure(configPath)
 	if err != nil {
 		return err
 	}
 
-	systemdStatus(SDReady, "Listening for incoming connections...")
+	c.DefaultLogger.Msg("configuration loaded")
 
+	if err := moduleStart(c); err != nil {
+		return err
+	}
+	c.DefaultLogger.Msg("server started", "version", Version)
+
+	systemdStatus(SDReady, "Listening for incoming connections...")
 	handleSignals()
 
+	c.DefaultLogger.Msg("server stopping...")
 	systemdStatus(SDStopping, "Waiting for running transactions to complete...")
 
-	hooks.RunHooks(hooks.EventShutdown)
-
+	moduleStop(c)
+	c.DefaultLogger.Msg("server stopped")
 	return nil
 }
 
-type ModInfo struct {
-	Instance module.Module
-	Cfg      config.Node
-}
-
-func RegisterModules(globals map[string]interface{}, nodes []config.Node) (endpoints, mods []ModInfo, err error) {
-	mods = make([]ModInfo, 0, len(nodes))
+func RegisterModules(c *container.C, globals map[string]interface{}, nodes []config.Node) (err error) {
+	var endpoints []struct {
+		Endpoint module.LifetimeModule
+		Cfg      *config.Map
+	}
 
 	for _, block := range nodes {
 		var instName string
@@ -364,70 +421,66 @@ func RegisterModules(globals map[string]interface{}, nodes []config.Node) (endpo
 		if endpFactory != nil {
 			inst, err := endpFactory(modName, block.Args)
 			if err != nil {
-				return nil, nil, err
+				return err
 			}
 
-			endpoints = append(endpoints, ModInfo{Instance: inst, Cfg: block})
+			endpoints = append(endpoints, struct {
+				Endpoint module.LifetimeModule
+				Cfg      *config.Map
+			}{Endpoint: inst, Cfg: config.NewMap(globals, block)})
 			continue
 		}
 
 		factory := module.Get(modName)
 		if factory == nil {
-			return nil, nil, config.NodeErr(block, "unknown module or global directive: %s", modName)
+			return config.NodeErr(block, "unknown module or global directive: %s", modName)
 		}
 
-		if module.HasInstance(instName) {
-			return nil, nil, config.NodeErr(block, "config block named %s already exists", instName)
+		inst, err := factory(modName, instName)
+		if err != nil {
+			return err
 		}
 
-		inst, err := factory(modName, instName, modAliases, nil)
+		err = c.Modules.Register(inst, func() error {
+			err := inst.Configure(nil, config.NewMap(globals, block))
+			if err != nil {
+				return err
+			}
+
+			if lt, ok := inst.(module.LifetimeModule); ok {
+				c.Lifetime.Add(lt)
+			}
+			return nil
+		})
 		if err != nil {
-			return nil, nil, err
+			if errors.Is(err, module.ErrInstanceNameDuplicate) {
+				return config.NodeErr(block, "config block named %s already exists", inst.InstanceName())
+			}
+			return err
 		}
 
-		module.RegisterInstance(inst, config.NewMap(globals, block))
 		for _, alias := range modAliases {
-			if module.HasInstance(alias) {
-				return nil, nil, config.NodeErr(block, "config block named %s already exists", alias)
+			if err := c.Modules.AddAlias(instName, alias); err != nil {
+				if errors.Is(err, module.ErrInstanceNameDuplicate) {
+					return config.NodeErr(block, "config block named %s already exists", alias)
+				}
+				return err
 			}
-			module.RegisterAlias(alias, instName)
 		}
 
 		log.Debugf("%v:%v: register config block %v %v", block.File, block.Line, instName, modAliases)
-		mods = append(mods, ModInfo{Instance: inst, Cfg: block})
 	}
 
 	if len(endpoints) == 0 {
-		return nil, nil, fmt.Errorf("at least one endpoint should be configured")
+		return fmt.Errorf("at least one endpoint should be configured")
 	}
 
-	return endpoints, mods, nil
-}
-
-func initModules(globals map[string]interface{}, endpoints, mods []ModInfo) error {
+	// Endpoints are configured directly after registration.
 	for _, endp := range endpoints {
-		if err := endp.Instance.Init(config.NewMap(globals, endp.Cfg)); err != nil {
+		if err := endp.Endpoint.Configure(nil, endp.Cfg); err != nil {
 			return err
 		}
-
-		if closer, ok := endp.Instance.(io.Closer); ok {
-			endp := endp
-			hooks.AddHook(hooks.EventShutdown, func() {
-				log.Debugf("close %s (%s)", endp.Instance.Name(), endp.Instance.InstanceName())
-				if err := closer.Close(); err != nil {
-					log.Printf("module %s (%s) close failed: %v", endp.Instance.Name(), endp.Instance.InstanceName(), err)
-				}
-			})
-		}
-	}
-
-	for _, inst := range mods {
-		if module.Initialized[inst.Instance.InstanceName()] {
-			continue
-		}
-
-		return fmt.Errorf("Unused configuration block at %s:%d - %s (%s)",
-			inst.Cfg.File, inst.Cfg.Line, inst.Instance.InstanceName(), inst.Instance.Name())
+		c.Lifetime.Add(endp.Endpoint)
 	}
 
 	return nil
diff --git a/signal_nonposix.go b/signal_nonposix.go
index 87ea0cd8..f5750a61 100644
--- a/signal_nonposix.go
+++ b/signal_nonposix.go
@@ -40,6 +40,6 @@ func handleSignals() os.Signal {
 		os.Exit(1)
 	}()
 
-	log.Printf("signal received (%v), next signal will force immediate shutdown.", s)
+	log.Printf("signal received (%v)", s)
 	return s
 }

From 3ce6ebf60c25114a91685cbcbd39bce0e3222b1a Mon Sep 17 00:00:00 2001
From: "fox.cpp" <fox.cpp@disroot.org>
Date: Wed, 29 Jan 2025 21:41:29 +0300
Subject: [PATCH 2/6] Add code to trigger module reinitialization on SIGUSR2

---
 maddy.go  | 40 +++++++++++++++++++++++++++++++++++++++-
 signal.go | 11 +++++------
 2 files changed, 44 insertions(+), 7 deletions(-)

diff --git a/maddy.go b/maddy.go
index 4b7204e1..829cdf76 100644
--- a/maddy.go
+++ b/maddy.go
@@ -376,6 +376,12 @@ func moduleStop(c *container.C) {
 func moduleMain(configPath string) error {
 	log.DefaultLogger.Msg("loading configuration...")
 
+	// Make path absolute to make sure we can still read it if current directory changes (in moduleConfigure).
+	configPath, err := filepath.Abs(configPath)
+	if err != nil {
+		return err
+	}
+
 	c, err := moduleConfigure(configPath)
 	if err != nil {
 		return err
@@ -389,7 +395,12 @@ func moduleMain(configPath string) error {
 	c.DefaultLogger.Msg("server started", "version", Version)
 
 	systemdStatus(SDReady, "Listening for incoming connections...")
-	handleSignals()
+	for handleSignals() {
+		systemdStatus(SDReloading, "Reloading state...")
+		hooks.RunHooks(hooks.EventReload)
+
+		c = moduleReload(c, configPath)
+	}
 
 	c.DefaultLogger.Msg("server stopping...")
 	systemdStatus(SDStopping, "Waiting for running transactions to complete...")
@@ -399,6 +410,33 @@ func moduleMain(configPath string) error {
 	return nil
 }
 
+func moduleReload(oldContainer *container.C, configPath string) *container.C {
+	oldContainer.DefaultLogger.Msg("reloading server...")
+
+	oldContainer.DefaultLogger.Msg("loading new configuration...")
+	newContainer, err := moduleConfigure(configPath)
+	if err != nil {
+		oldContainer.DefaultLogger.Error("failed to load new configuration", err)
+		return oldContainer
+	}
+
+	oldContainer.DefaultLogger.Msg("configuration loaded")
+
+	oldContainer.DefaultLogger.Msg("starting new server")
+	if err := moduleStart(newContainer); err != nil {
+		oldContainer.DefaultLogger.Error("failed to start new server", err)
+		container.Global = oldContainer
+		return oldContainer
+	}
+
+	newContainer.DefaultLogger.Msg("server started", "version", Version)
+	oldContainer.DefaultLogger.Msg("stopping server")
+	moduleStop(oldContainer)
+	oldContainer.DefaultLogger.Msg("server stopped")
+
+	return newContainer
+}
+
 func RegisterModules(c *container.C, globals map[string]interface{}, nodes []config.Node) (err error) {
 	var endpoints []struct {
 		Endpoint module.LifetimeModule
diff --git a/signal.go b/signal.go
index e952fcf6..4925b3db 100644
--- a/signal.go
+++ b/signal.go
@@ -36,9 +36,10 @@ import (
 // (SIGTERM, SIGHUP, SIGINT) will cause this function to return.
 //
 // SIGUSR1 will call reinitLogging without returning.
-func handleSignals() os.Signal {
+func handleSignals() (reload bool) {
 	sig := make(chan os.Signal, 5)
 	signal.Notify(sig, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGINT, syscall.SIGUSR1, syscall.SIGUSR2)
+	defer signal.Stop(sig)
 
 	for {
 		switch s := <-sig; s {
@@ -48,10 +49,8 @@ func handleSignals() os.Signal {
 			hooks.RunHooks(hooks.EventLogRotate)
 			systemdStatus(SDReady, "Listening for incoming connections...")
 		case syscall.SIGUSR2:
-			log.Printf("signal received (%s), reloading state", s.String())
-			systemdStatus(SDReloading, "Reloading state...")
-			hooks.RunHooks(hooks.EventReload)
-			systemdStatus(SDReady, "Listening for incoming connections...")
+			log.Printf("signal received (%s), reloading configuration", s.String())
+			return true
 		default:
 			go func() {
 				s := handleSignals()
@@ -60,7 +59,7 @@ func handleSignals() os.Signal {
 			}()
 
 			log.Printf("signal received (%v), next signal will force immediate shutdown.", s)
-			return s
+			return false
 		}
 	}
 }

From f82742b392bfc459dc767ead2636a9df60e76356 Mon Sep 17 00:00:00 2001
From: "fox.cpp" <fox.cpp@disroot.org>
Date: Thu, 30 Jan 2025 01:45:19 +0300
Subject: [PATCH 3/6] Implement net.Listener hand-over during config reload

Log library refactored a little to make it easier to enable debug logging.
---
 framework/log/log.go                          | 67 ++++++++------
 framework/log/orderedjson.go                  |  2 +-
 framework/log/zap.go                          |  2 +-
 framework/module/lifetime.go                  |  4 +-
 framework/module/registry.go                  |  4 +-
 framework/resource/netresource/dup.go         | 27 ++++++
 framework/resource/netresource/fd.go          | 47 ++++++++++
 framework/resource/netresource/listen.go      | 38 ++++++++
 framework/resource/netresource/tracker.go     | 91 +++++++++++++++++++
 framework/resource/resource.go                | 18 ++++
 framework/resource/singleton.go               | 64 +++++++++++++
 framework/resource/tracker.go                 | 47 ++++++++++
 internal/auth/netauth/netauth.go              |  2 +-
 .../endpoint/dovecot_sasld/dovecot_sasl.go    |  5 +-
 internal/endpoint/imap/imap.go                |  5 +-
 internal/endpoint/openmetrics/om.go           |  4 +-
 internal/endpoint/smtp/smtp.go                |  5 +-
 internal/updatepipe/unix_pipe.go              |  3 +-
 maddy.go                                      |  3 +
 19 files changed, 394 insertions(+), 44 deletions(-)
 create mode 100644 framework/resource/netresource/dup.go
 create mode 100644 framework/resource/netresource/fd.go
 create mode 100644 framework/resource/netresource/listen.go
 create mode 100644 framework/resource/netresource/tracker.go
 create mode 100644 framework/resource/resource.go
 create mode 100644 framework/resource/singleton.go
 create mode 100644 framework/resource/tracker.go

diff --git a/framework/log/log.go b/framework/log/log.go
index da8647f6..366cf156 100644
--- a/framework/log/log.go
+++ b/framework/log/log.go
@@ -42,6 +42,8 @@ import (
 // No serialization is provided by Logger, its log.Output responsibility to
 // ensure goroutine-safety if necessary.
 type Logger struct {
+	Parent *Logger
+
 	Out   Output
 	Name  string
 	Debug bool
@@ -51,30 +53,37 @@ type Logger struct {
 	Fields map[string]interface{}
 }
 
-func (l Logger) Zap() *zap.Logger {
+func (l *Logger) Zap() *zap.Logger {
 	// TODO: Migrate to using zap natively.
 	return zap.New(zapLogger{L: l})
 }
 
-func (l Logger) Debugf(format string, val ...interface{}) {
-	if !l.Debug {
+func (l *Logger) IsDebug() bool {
+	if l.Parent == nil {
+		return l.Debug
+	}
+	return l.Debug || l.Parent.IsDebug()
+}
+
+func (l *Logger) Debugf(format string, val ...interface{}) {
+	if !l.IsDebug() {
 		return
 	}
 	l.log(true, l.formatMsg(fmt.Sprintf(format, val...), nil))
 }
 
-func (l Logger) Debugln(val ...interface{}) {
-	if !l.Debug {
+func (l *Logger) Debugln(val ...interface{}) {
+	if !l.IsDebug() {
 		return
 	}
 	l.log(true, l.formatMsg(strings.TrimRight(fmt.Sprintln(val...), "\n"), nil))
 }
 
-func (l Logger) Printf(format string, val ...interface{}) {
+func (l *Logger) Printf(format string, val ...interface{}) {
 	l.log(false, l.formatMsg(fmt.Sprintf(format, val...), nil))
 }
 
-func (l Logger) Println(val ...interface{}) {
+func (l *Logger) Println(val ...interface{}) {
 	l.log(false, l.formatMsg(strings.TrimRight(fmt.Sprintln(val...), "\n"), nil))
 }
 
@@ -87,13 +96,13 @@ func (l Logger) Println(val ...interface{}) {
 // followed by corresponding values.  That is, for example, []interface{"key",
 // "value", "key2", "value2"}.
 //
-// If value in fields implements LogFormatter, it will be represented by the
+// If value in fields implements Formatter, it will be represented by the
 // string returned by FormatLog method. Same goes for fmt.Stringer and error
 // interfaces.
 //
 // Additionally, time.Time is written as a string in ISO 8601 format.
 // time.Duration follows fmt.Stringer rule above.
-func (l Logger) Msg(msg string, fields ...interface{}) {
+func (l *Logger) Msg(msg string, fields ...interface{}) {
 	m := make(map[string]interface{}, len(fields)/2)
 	fieldsToMap(fields, m)
 	l.log(false, l.formatMsg(msg, m))
@@ -112,7 +121,7 @@ func (l Logger) Msg(msg string, fields ...interface{}) {
 // In the context of Error method, "msg" typically indicates the top-level
 // context in which the error is *handled*. For example, if error leads to
 // rejection of SMTP DATA command, msg will probably be "DATA error".
-func (l Logger) Error(msg string, err error, fields ...interface{}) {
+func (l *Logger) Error(msg string, err error, fields ...interface{}) {
 	if err == nil {
 		return
 	}
@@ -133,8 +142,8 @@ func (l Logger) Error(msg string, err error, fields ...interface{}) {
 	l.log(false, l.formatMsg(msg, allFields))
 }
 
-func (l Logger) DebugMsg(kind string, fields ...interface{}) {
-	if !l.Debug {
+func (l *Logger) DebugMsg(kind string, fields ...interface{}) {
+	if !l.IsDebug() {
 		return
 	}
 	m := make(map[string]interface{}, len(fields)/2)
@@ -162,7 +171,7 @@ func fieldsToMap(fields []interface{}, out map[string]interface{}) {
 	}
 }
 
-func (l Logger) formatMsg(msg string, fields map[string]interface{}) string {
+func (l *Logger) formatMsg(msg string, fields map[string]interface{}) string {
 	formatted := strings.Builder{}
 
 	formatted.WriteString(msg)
@@ -184,14 +193,17 @@ func (l Logger) formatMsg(msg string, fields map[string]interface{}) string {
 	return formatted.String()
 }
 
-type LogFormatter interface {
+type Formatter interface {
 	FormatLog() string
 }
 
 // Write implements io.Writer, all bytes sent
 // to it will be written as a separate log messages.
 // No line-buffering is done.
-func (l Logger) Write(s []byte) (int, error) {
+func (l *Logger) Write(s []byte) (int, error) {
+	if !l.IsDebug() {
+		return len(s), nil
+	}
 	l.log(false, strings.TrimRight(string(s), "\n"))
 	return len(s), nil
 }
@@ -199,15 +211,13 @@ func (l Logger) Write(s []byte) (int, error) {
 // DebugWriter returns a writer that will act like Logger.Write
 // but will use debug flag on messages. If Logger.Debug is false,
 // Write method of returned object will be no-op.
-func (l Logger) DebugWriter() io.Writer {
-	if !l.Debug {
-		return io.Discard
-	}
-	l.Debug = true
-	return &l
+func (l *Logger) DebugWriter() io.Writer {
+	l2 := l.Sublogger("")
+	l2.Debug = true
+	return l2
 }
 
-func (l Logger) log(debug bool, s string) {
+func (l *Logger) log(debug bool, s string) {
 	if l.Name != "" {
 		s = l.Name + ": " + s
 	}
@@ -224,14 +234,15 @@ func (l Logger) log(debug bool, s string) {
 	// Logging is disabled - do nothing.
 }
 
-func (l Logger) Sublogger(name string) Logger {
-	if l.Name != "" {
+func (l *Logger) Sublogger(name string) *Logger {
+	if l.Name != "" && name != "" {
 		name = l.Name + "/" + name
 	}
-	return Logger{
-		Out:   l.Out,
-		Name:  name,
-		Debug: l.Debug,
+	return &Logger{
+		Parent: l,
+		Out:    l.Out,
+		Name:   name,
+		Debug:  l.Debug,
 	}
 }
 
diff --git a/framework/log/orderedjson.go b/framework/log/orderedjson.go
index 834f3a6c..9de32baf 100644
--- a/framework/log/orderedjson.go
+++ b/framework/log/orderedjson.go
@@ -58,7 +58,7 @@ func marshalOrderedJSON(output *strings.Builder, m map[string]interface{}) error
 			val = casted.Format("2006-01-02T15:04:05.000")
 		case time.Duration:
 			val = casted.String()
-		case LogFormatter:
+		case Formatter:
 			val = casted.FormatLog()
 		case fmt.Stringer:
 			val = casted.String()
diff --git a/framework/log/zap.go b/framework/log/zap.go
index 23821f84..893bf484 100644
--- a/framework/log/zap.go
+++ b/framework/log/zap.go
@@ -7,7 +7,7 @@ import (
 // TODO: Migrate to using actual zapcore to improve logging performance
 
 type zapLogger struct {
-	L Logger
+	L *Logger
 }
 
 func (l zapLogger) Enabled(level zapcore.Level) bool {
diff --git a/framework/module/lifetime.go b/framework/module/lifetime.go
index 1b0339da..ede5d2b4 100644
--- a/framework/module/lifetime.go
+++ b/framework/module/lifetime.go
@@ -38,7 +38,7 @@ type ReloadModule interface {
 }
 
 type LifetimeTracker struct {
-	logger    log.Logger
+	logger    *log.Logger
 	instances []*struct {
 		mod     LifetimeModule
 		started bool
@@ -114,7 +114,7 @@ func (lt *LifetimeTracker) StopAll() error {
 	return nil
 }
 
-func NewLifetime(log log.Logger) *LifetimeTracker {
+func NewLifetime(log *log.Logger) *LifetimeTracker {
 	return &LifetimeTracker{
 		logger: log,
 	}
diff --git a/framework/module/registry.go b/framework/module/registry.go
index ec62edf7..ce133402 100644
--- a/framework/module/registry.go
+++ b/framework/module/registry.go
@@ -35,14 +35,14 @@ type registryEntry struct {
 }
 
 type Registry struct {
-	logger      log.Logger
+	logger      *log.Logger
 	instances   map[string]registryEntry
 	initialized map[string]struct{}
 	started     map[string]struct{}
 	aliases     map[string]string
 }
 
-func NewRegistry(log log.Logger) *Registry {
+func NewRegistry(log *log.Logger) *Registry {
 	return &Registry{
 		logger:      log,
 		instances:   make(map[string]registryEntry),
diff --git a/framework/resource/netresource/dup.go b/framework/resource/netresource/dup.go
new file mode 100644
index 00000000..00d047dc
--- /dev/null
+++ b/framework/resource/netresource/dup.go
@@ -0,0 +1,27 @@
+package netresource
+
+import "net"
+
+func dupTCPListener(l *net.TCPListener) (*net.TCPListener, error) {
+	f, err := l.File()
+	if err != nil {
+		return nil, err
+	}
+	l2, err := net.FileListener(f)
+	if err != nil {
+		return nil, err
+	}
+	return l2.(*net.TCPListener), nil
+}
+
+func dupUnixListener(l *net.UnixListener) (*net.UnixListener, error) {
+	f, err := l.File()
+	if err != nil {
+		return nil, err
+	}
+	l2, err := net.FileListener(f)
+	if err != nil {
+		return nil, err
+	}
+	return l2.(*net.UnixListener), nil
+}
diff --git a/framework/resource/netresource/fd.go b/framework/resource/netresource/fd.go
new file mode 100644
index 00000000..395ddbcc
--- /dev/null
+++ b/framework/resource/netresource/fd.go
@@ -0,0 +1,47 @@
+package netresource
+
+import (
+	"errors"
+	"fmt"
+	"net"
+	"os"
+	"strconv"
+	"strings"
+)
+
+func ListenFD(fd uint) (net.Listener, error) {
+	file := os.NewFile(uintptr(fd), strconv.FormatUint(uint64(fd), 10))
+	defer file.Close()
+	return net.FileListener(file)
+}
+
+func ListenFDName(name string) (net.Listener, error) {
+	listenPDStr := os.Getenv("LISTEN_PID")
+	if listenPDStr == "" {
+		return nil, errors.New("$LISTEN_PID is not set")
+	}
+	listenPid, err := strconv.Atoi(listenPDStr)
+	if err != nil {
+		return nil, errors.New("$LISTEN_PID is not integer")
+	}
+	if listenPid != os.Getpid() {
+		return nil, fmt.Errorf("$LISTEN_PID (%d) is not our PID (%d)", listenPid, os.Getpid())
+	}
+
+	names := strings.Split(os.Getenv("LISTEN_FDNAMES"), ":")
+	fd := uintptr(0)
+	for i, fdName := range names {
+		if fdName == name {
+			fd = uintptr(3 + i)
+			break
+		}
+	}
+
+	if fd == 0 {
+		return nil, fmt.Errorf("name %s not found in $LISTEN_FDNAMES", name)
+	}
+
+	file := os.NewFile(3+fd, name)
+	defer file.Close()
+	return net.FileListener(file)
+}
diff --git a/framework/resource/netresource/listen.go b/framework/resource/netresource/listen.go
new file mode 100644
index 00000000..e2328002
--- /dev/null
+++ b/framework/resource/netresource/listen.go
@@ -0,0 +1,38 @@
+package netresource
+
+import (
+	"fmt"
+	"net"
+	"strconv"
+
+	"github.com/foxcpp/maddy/framework/log"
+)
+
+var (
+	tracker = NewListenerTracker(log.DefaultLogger.Sublogger("netresource"))
+)
+
+func CloseUnusedListeners() error {
+	return tracker.Close()
+}
+
+func ResetListenersUsage() {
+	tracker.ResetUsage()
+}
+
+func Listen(network, addr string) (net.Listener, error) {
+	switch network {
+	case "fd":
+		fd, err := strconv.ParseUint(addr, 10, strconv.IntSize)
+		if err != nil {
+			return nil, fmt.Errorf("invalid FD number: %v", addr)
+		}
+		return ListenFD(uint(fd))
+	case "fdname":
+		return ListenFDName(addr)
+	case "tcp", "tcp4", "tcp6", "unix":
+		return tracker.Get(network, addr)
+	default:
+		return nil, fmt.Errorf("unsupported network: %v", network)
+	}
+}
diff --git a/framework/resource/netresource/tracker.go b/framework/resource/netresource/tracker.go
new file mode 100644
index 00000000..97989c62
--- /dev/null
+++ b/framework/resource/netresource/tracker.go
@@ -0,0 +1,91 @@
+package netresource
+
+import (
+	"fmt"
+	"net"
+	"net/netip"
+
+	"github.com/foxcpp/maddy/framework/log"
+	"github.com/foxcpp/maddy/framework/resource"
+)
+
+type ListenerTracker struct {
+	logger *log.Logger
+	tcp    *resource.Tracker[*net.TCPListener]
+	unix   *resource.Tracker[*net.UnixListener]
+}
+
+func (lt *ListenerTracker) Get(network, addr string) (net.Listener, error) {
+	switch network {
+	case "tcp", "tcp4", "tcp6":
+		l, err := lt.tcp.GetOpen(addr, func() (*net.TCPListener, error) {
+			addrPort, err := netip.ParseAddrPort(addr)
+			if err != nil {
+				return nil, err
+			}
+			lt.logger.DebugMsg("new listener", "network", network, "address", addr)
+			return net.ListenTCP(network, net.TCPAddrFromAddrPort(addrPort))
+		})
+		if err != nil {
+			return nil, err
+		}
+
+		// We return duplicated listener so when listener is closed by user endpoint
+		// the tracked resource remains available and listening on the port doesn't
+		// actually stop.
+		l2, err := dupTCPListener(l)
+		if err != nil {
+			return nil, err
+		}
+		return l2, nil
+	case "unix":
+		l, err := lt.unix.GetOpen(addr, func() (*net.UnixListener, error) {
+			addr, err := net.ResolveUnixAddr(network, addr)
+			if err != nil {
+				return nil, err
+			}
+			lt.logger.DebugMsg("new listener", "network", network, "address", addr)
+			return net.ListenUnix(network, addr)
+		})
+		if err != nil {
+			return nil, err
+		}
+
+		l2, err := dupUnixListener(l)
+		if err != nil {
+			return nil, err
+		}
+		return l2, nil
+	default:
+		return nil, fmt.Errorf("unsupported network type: %s", network)
+	}
+}
+
+func (lt *ListenerTracker) ResetUsage() {
+	lt.tcp.MarkAllUnused()
+	lt.unix.MarkAllUnused()
+}
+
+func (lt *ListenerTracker) CloseUnused() error {
+	lt.tcp.CloseUnused(func(key string) bool {
+		return false
+	})
+	lt.unix.CloseUnused(func(key string) bool {
+		return false
+	})
+	return nil
+}
+
+func (lt *ListenerTracker) Close() error {
+	lt.tcp.Close()
+	lt.unix.Close()
+	return nil
+}
+
+func NewListenerTracker(log *log.Logger) *ListenerTracker {
+	return &ListenerTracker{
+		logger: log,
+		tcp:    resource.NewTracker[*net.TCPListener](resource.NewSingleton[*net.TCPListener]()),
+		unix:   resource.NewTracker[*net.UnixListener](resource.NewSingleton[*net.UnixListener]()),
+	}
+}
diff --git a/framework/resource/resource.go b/framework/resource/resource.go
new file mode 100644
index 00000000..a34942a6
--- /dev/null
+++ b/framework/resource/resource.go
@@ -0,0 +1,18 @@
+package resource
+
+import (
+	"io"
+)
+
+type Resource = io.Closer
+
+type CheckableResource interface {
+	Resource
+	IsUsable() bool
+}
+
+type Container[T Resource] interface {
+	io.Closer
+	GetOpen(key string, open func() (T, error)) (T, error)
+	CloseUnused(isUsed func(key string) bool) error
+}
diff --git a/framework/resource/singleton.go b/framework/resource/singleton.go
new file mode 100644
index 00000000..2614cee2
--- /dev/null
+++ b/framework/resource/singleton.go
@@ -0,0 +1,64 @@
+package resource
+
+import (
+	"sync"
+)
+
+// Singleton represents a set of resources identified by an unique key.
+type Singleton[T Resource] struct {
+	lock      sync.RWMutex
+	resources map[string]T
+}
+
+func NewSingleton[T Resource]() *Singleton[T] {
+	return &Singleton[T]{
+		resources: make(map[string]T),
+	}
+}
+
+func (s *Singleton[T]) GetOpen(key string, open func() (T, error)) (T, error) {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	existing, ok := s.resources[key]
+	if ok {
+		return existing, nil
+	}
+
+	res, err := open()
+	if err != nil {
+		var empty T
+		return empty, err
+	}
+
+	s.resources[key] = res
+
+	return res, nil
+}
+
+func (s *Singleton[T]) CloseUnused(isUsed func(key string) bool) error {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	for key, res := range s.resources {
+		if isUsed(key) {
+			continue
+		}
+		res.Close()
+		delete(s.resources, key)
+	}
+
+	return nil
+}
+
+func (s *Singleton[T]) Close() error {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	for key, res := range s.resources {
+		res.Close()
+		delete(s.resources, key)
+	}
+
+	return nil
+}
diff --git a/framework/resource/tracker.go b/framework/resource/tracker.go
new file mode 100644
index 00000000..318cf298
--- /dev/null
+++ b/framework/resource/tracker.go
@@ -0,0 +1,47 @@
+package resource
+
+import (
+	"sync"
+)
+
+// Tracker is a container wrapper that tracks whether resources were used since
+// last MarkAllUnused call.
+type Tracker[T Resource] struct {
+	C Container[T]
+
+	usedLock sync.Mutex
+	used     map[string]bool
+}
+
+func NewTracker[T Resource](c Container[T]) *Tracker[T] {
+	return &Tracker[T]{C: c, used: make(map[string]bool)}
+}
+
+func (t *Tracker[T]) Close() error {
+	return t.C.Close()
+}
+
+func (t *Tracker[T]) MarkAllUnused() {
+	t.usedLock.Lock()
+	defer t.usedLock.Unlock()
+
+	t.used = make(map[string]bool)
+}
+
+func (t *Tracker[T]) GetOpen(key string, open func() (T, error)) (T, error) {
+	t.usedLock.Lock()
+	t.used[key] = true
+	t.usedLock.Unlock()
+
+	return t.C.GetOpen(key, open)
+}
+
+func (t *Tracker[T]) CloseUnused(isUsed func(key string) bool) error {
+	t.usedLock.Lock()
+	defer t.usedLock.Unlock()
+
+	return t.C.CloseUnused(func(key string) bool {
+		used := t.used[key]
+		return used && isUsed(key)
+	})
+}
diff --git a/internal/auth/netauth/netauth.go b/internal/auth/netauth/netauth.go
index 62d6c6cc..db84cd18 100644
--- a/internal/auth/netauth/netauth.go
+++ b/internal/auth/netauth/netauth.go
@@ -42,7 +42,7 @@ func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error {
 		return fmt.Errorf("%s: inline arguments are not used", modName)
 	}
 
-	l := hclog.New(&hclog.LoggerOptions{Output: a.log})
+	l := hclog.New(&hclog.LoggerOptions{Output: &a.log})
 	n, err := netauth.NewWithLog(l)
 	if err != nil {
 		return err
diff --git a/internal/endpoint/dovecot_sasld/dovecot_sasl.go b/internal/endpoint/dovecot_sasld/dovecot_sasl.go
index b1bcd159..f4dee1a7 100644
--- a/internal/endpoint/dovecot_sasld/dovecot_sasl.go
+++ b/internal/endpoint/dovecot_sasld/dovecot_sasl.go
@@ -31,6 +31,7 @@ import (
 	modconfig "github.com/foxcpp/maddy/framework/config/module"
 	"github.com/foxcpp/maddy/framework/log"
 	"github.com/foxcpp/maddy/framework/module"
+	"github.com/foxcpp/maddy/framework/resource/netresource"
 	"github.com/foxcpp/maddy/internal/auth"
 	"github.com/foxcpp/maddy/internal/authz"
 )
@@ -79,7 +80,7 @@ func (endp *Endpoint) Configure(_ []string, cfg *config.Map) error {
 	}
 
 	endp.srv = dovecotsasl.NewServer()
-	endp.srv.Log = stdlog.New(endp.log, "", 0)
+	endp.srv.Log = stdlog.New(&endp.log, "", 0)
 
 	for _, mech := range endp.saslAuth.SASLMechanisms() {
 		endp.srv.AddMechanism(mech, mechInfo[mech], func(req *dovecotsasl.AuthReq) sasl.Server {
@@ -106,7 +107,7 @@ func (endp *Endpoint) Configure(_ []string, cfg *config.Map) error {
 
 func (endp *Endpoint) Start() error {
 	for _, addr := range endp.endpoints {
-		l, err := net.Listen(addr.Network(), addr.Address())
+		l, err := netresource.Listen(addr.Network(), addr.Address())
 		if err != nil {
 			return fmt.Errorf("%s: %v", modName, err)
 		}
diff --git a/internal/endpoint/imap/imap.go b/internal/endpoint/imap/imap.go
index 765c429f..13116225 100644
--- a/internal/endpoint/imap/imap.go
+++ b/internal/endpoint/imap/imap.go
@@ -42,6 +42,7 @@ import (
 	tls2 "github.com/foxcpp/maddy/framework/config/tls"
 	"github.com/foxcpp/maddy/framework/log"
 	"github.com/foxcpp/maddy/framework/module"
+	"github.com/foxcpp/maddy/framework/resource/netresource"
 	"github.com/foxcpp/maddy/internal/auth"
 	"github.com/foxcpp/maddy/internal/authz"
 	"github.com/foxcpp/maddy/internal/proxy_protocol"
@@ -126,7 +127,7 @@ func (endp *Endpoint) Configure(_ []string, cfg *config.Map) error {
 	if ioErrors {
 		endp.serv.ErrorLog = &endp.Log
 	} else {
-		endp.serv.ErrorLog = log.Logger{Out: log.NopOutput{}}
+		endp.serv.ErrorLog = &log.Logger{Out: log.NopOutput{}}
 	}
 	if ioDebug {
 		endp.serv.Debug = endp.Log.DebugWriter()
@@ -174,7 +175,7 @@ func (endp *Endpoint) setupListeners(addresses []config.Endpoint) error {
 	for _, addr := range addresses {
 		var l net.Listener
 		var err error
-		l, err = net.Listen(addr.Network(), addr.Address())
+		l, err = netresource.Listen(addr.Network(), addr.Address())
 		if err != nil {
 			return fmt.Errorf("imap: %v", err)
 		}
diff --git a/internal/endpoint/openmetrics/om.go b/internal/endpoint/openmetrics/om.go
index 136d4af5..44ac64db 100644
--- a/internal/endpoint/openmetrics/om.go
+++ b/internal/endpoint/openmetrics/om.go
@@ -21,13 +21,13 @@ package openmetrics
 import (
 	"errors"
 	"fmt"
-	"net"
 	"net/http"
 	"sync"
 
 	"github.com/foxcpp/maddy/framework/config"
 	"github.com/foxcpp/maddy/framework/log"
 	"github.com/foxcpp/maddy/framework/module"
+	"github.com/foxcpp/maddy/framework/resource/netresource"
 	"github.com/prometheus/client_golang/prometheus/promhttp"
 )
 
@@ -83,7 +83,7 @@ func (e *Endpoint) InstanceName() string {
 
 func (e *Endpoint) Start() error {
 	for _, endp := range e.endpoints {
-		l, err := net.Listen(endp.Network(), endp.Address())
+		l, err := netresource.Listen(endp.Network(), endp.Address())
 		if err != nil {
 			e.Stop()
 			return fmt.Errorf("%s: %v", modName, err)
diff --git a/internal/endpoint/smtp/smtp.go b/internal/endpoint/smtp/smtp.go
index 7e5f3ab0..ee433e86 100644
--- a/internal/endpoint/smtp/smtp.go
+++ b/internal/endpoint/smtp/smtp.go
@@ -41,6 +41,7 @@ import (
 	"github.com/foxcpp/maddy/framework/future"
 	"github.com/foxcpp/maddy/framework/log"
 	"github.com/foxcpp/maddy/framework/module"
+	"github.com/foxcpp/maddy/framework/resource/netresource"
 	"github.com/foxcpp/maddy/internal/auth"
 	"github.com/foxcpp/maddy/internal/authz"
 	"github.com/foxcpp/maddy/internal/limits"
@@ -107,7 +108,7 @@ func New(modName string, addrs []string) (module.LifetimeModule, error) {
 
 func (endp *Endpoint) Configure(_ []string, cfg *config.Map) error {
 	endp.serv = smtp.NewServer(endp)
-	endp.serv.ErrorLog = endp.Log
+	endp.serv.ErrorLog = &endp.Log
 	endp.serv.LMTP = endp.lmtp
 	endp.serv.EnableSMTPUTF8 = true
 	endp.serv.EnableREQUIRETLS = true
@@ -328,7 +329,7 @@ func (endp *Endpoint) setupListeners(addresses []config.Endpoint) error {
 	for _, addr := range addresses {
 		var l net.Listener
 		var err error
-		l, err = net.Listen(addr.Network(), addr.Address())
+		l, err = netresource.Listen(addr.Network(), addr.Address())
 		if err != nil {
 			return fmt.Errorf("%s: %w", endp.name, err)
 		}
diff --git a/internal/updatepipe/unix_pipe.go b/internal/updatepipe/unix_pipe.go
index a8249f90..945c4f95 100644
--- a/internal/updatepipe/unix_pipe.go
+++ b/internal/updatepipe/unix_pipe.go
@@ -27,6 +27,7 @@ import (
 
 	mess "github.com/foxcpp/go-imap-mess"
 	"github.com/foxcpp/maddy/framework/log"
+	"github.com/foxcpp/maddy/framework/resource/netresource"
 )
 
 // UnixSockPipe implements the UpdatePipe interface by serializating updates
@@ -74,7 +75,7 @@ func (usp *UnixSockPipe) readUpdates(conn net.Conn, updCh chan<- mess.Update) {
 }
 
 func (usp *UnixSockPipe) Listen(upd chan<- mess.Update) error {
-	l, err := net.Listen("unix", usp.SockPath)
+	l, err := netresource.Listen("unix", usp.SockPath)
 	if err != nil {
 		return err
 	}
diff --git a/maddy.go b/maddy.go
index 829cdf76..e0a33e1d 100644
--- a/maddy.go
+++ b/maddy.go
@@ -36,6 +36,7 @@ import (
 	"github.com/foxcpp/maddy/framework/hooks"
 	"github.com/foxcpp/maddy/framework/log"
 	"github.com/foxcpp/maddy/framework/module"
+	"github.com/foxcpp/maddy/framework/resource/netresource"
 	"github.com/foxcpp/maddy/internal/authz"
 	maddycli "github.com/foxcpp/maddy/internal/cli"
 	"github.com/urfave/cli/v2"
@@ -422,12 +423,14 @@ func moduleReload(oldContainer *container.C, configPath string) *container.C {
 
 	oldContainer.DefaultLogger.Msg("configuration loaded")
 
+	netresource.ResetListenersUsage()
 	oldContainer.DefaultLogger.Msg("starting new server")
 	if err := moduleStart(newContainer); err != nil {
 		oldContainer.DefaultLogger.Error("failed to start new server", err)
 		container.Global = oldContainer
 		return oldContainer
 	}
+	netresource.CloseUnusedListeners()
 
 	newContainer.DefaultLogger.Msg("server started", "version", Version)
 	oldContainer.DefaultLogger.Msg("stopping server")

From d712d8cc64e6d01437dc74a0f5a3918d06a1e695 Mon Sep 17 00:00:00 2001
From: "fox.cpp" <fox.cpp@disroot.org>
Date: Thu, 30 Jan 2025 03:36:55 +0300
Subject: [PATCH 4/6] netresource: Improve logging, fix bug with listeners
 being recreated on reload

---
 dist/systemd/maddy.service                |  1 -
 dist/systemd/maddy@.service               |  1 -
 docs/reference/endpoints/smtp.md          |  9 ++++++
 framework/module/lifetime.go              |  4 ++-
 framework/resource/netresource/listen.go  |  6 +++-
 framework/resource/netresource/tracker.go | 16 +++++-----
 framework/resource/singleton.go           | 10 ++++++-
 framework/resource/tracker.go             |  6 +++-
 internal/endpoint/smtp/smtp.go            | 11 +++++--
 maddy.go                                  | 36 +++++++++++++++++------
 10 files changed, 74 insertions(+), 26 deletions(-)

diff --git a/dist/systemd/maddy.service b/dist/systemd/maddy.service
index ec1ac29c..18236568 100644
--- a/dist/systemd/maddy.service
+++ b/dist/systemd/maddy.service
@@ -75,7 +75,6 @@ RestartPreventExitStatus=2
 
 ExecStart=/usr/local/bin/maddy run
 
-ExecReload=/bin/kill -USR1 $MAINPID
 ExecReload=/bin/kill -USR2 $MAINPID
 
 [Install]
diff --git a/dist/systemd/maddy@.service b/dist/systemd/maddy@.service
index ea60ff84..4ba3b54d 100644
--- a/dist/systemd/maddy@.service
+++ b/dist/systemd/maddy@.service
@@ -71,7 +71,6 @@ RestartPreventExitStatus=2
 
 ExecStart=/usr/local/bin/maddy --config /etc/maddy/%i.conf run
 
-ExecReload=/bin/kill -USR1 $MAINPID
 ExecReload=/bin/kill -USR2 $MAINPID
 
 [Install]
diff --git a/docs/reference/endpoints/smtp.md b/docs/reference/endpoints/smtp.md
index 4dfa723a..58d3d9b1 100644
--- a/docs/reference/endpoints/smtp.md
+++ b/docs/reference/endpoints/smtp.md
@@ -14,6 +14,7 @@ smtp tcp://0.0.0.0:25 {
     sasl_login no
     read_timeout 10m
     write_timeout 1m
+    shutdown_timeout 3m
     max_message_size 32M
     max_header_size 1M
     auth pam
@@ -125,6 +126,14 @@ I/O write timeout.
 
 ---
 
+### shutdown_timeout _duration_
+Default: `3m`
+
+Time to wait until forcibly closing connections on server shutdown
+or configuration reload.
+
+---
+
 ### max_message_size _size_
 Default: `32M`
 
diff --git a/framework/module/lifetime.go b/framework/module/lifetime.go
index ede5d2b4..5338b01f 100644
--- a/framework/module/lifetime.go
+++ b/framework/module/lifetime.go
@@ -96,7 +96,9 @@ func (lt *LifetimeTracker) ReloadAll() error {
 
 // StopAll calls Stop for all registered LifetimeModule instances.
 func (lt *LifetimeTracker) StopAll() error {
-	for _, entry := range lt.instances {
+	for i := len(lt.instances) - 1; i >= 0; i-- {
+		entry := lt.instances[i]
+
 		if !entry.started {
 			continue
 		}
diff --git a/framework/resource/netresource/listen.go b/framework/resource/netresource/listen.go
index e2328002..6d164fd7 100644
--- a/framework/resource/netresource/listen.go
+++ b/framework/resource/netresource/listen.go
@@ -13,7 +13,11 @@ var (
 )
 
 func CloseUnusedListeners() error {
-	return tracker.Close()
+	return tracker.CloseUnused()
+}
+
+func CloseAllListeners() {
+	tracker.Close()
 }
 
 func ResetListenersUsage() {
diff --git a/framework/resource/netresource/tracker.go b/framework/resource/netresource/tracker.go
index 97989c62..a1827aa2 100644
--- a/framework/resource/netresource/tracker.go
+++ b/framework/resource/netresource/tracker.go
@@ -67,12 +67,8 @@ func (lt *ListenerTracker) ResetUsage() {
 }
 
 func (lt *ListenerTracker) CloseUnused() error {
-	lt.tcp.CloseUnused(func(key string) bool {
-		return false
-	})
-	lt.unix.CloseUnused(func(key string) bool {
-		return false
-	})
+	lt.tcp.CloseUnused(func(key string) bool { return true })
+	lt.unix.CloseUnused(func(key string) bool { return true })
 	return nil
 }
 
@@ -83,9 +79,11 @@ func (lt *ListenerTracker) Close() error {
 }
 
 func NewListenerTracker(log *log.Logger) *ListenerTracker {
-	return &ListenerTracker{
+	lt := &ListenerTracker{
 		logger: log,
-		tcp:    resource.NewTracker[*net.TCPListener](resource.NewSingleton[*net.TCPListener]()),
-		unix:   resource.NewTracker[*net.UnixListener](resource.NewSingleton[*net.UnixListener]()),
+		tcp:    resource.NewTracker[*net.TCPListener](resource.NewSingleton[*net.TCPListener](log.Sublogger("tcp"))),
+		unix:   resource.NewTracker[*net.UnixListener](resource.NewSingleton[*net.UnixListener](log.Sublogger("unix"))),
 	}
+
+	return lt
 }
diff --git a/framework/resource/singleton.go b/framework/resource/singleton.go
index 2614cee2..45f5b903 100644
--- a/framework/resource/singleton.go
+++ b/framework/resource/singleton.go
@@ -2,16 +2,20 @@ package resource
 
 import (
 	"sync"
+
+	"github.com/foxcpp/maddy/framework/log"
 )
 
 // Singleton represents a set of resources identified by an unique key.
 type Singleton[T Resource] struct {
+	log       *log.Logger
 	lock      sync.RWMutex
 	resources map[string]T
 }
 
-func NewSingleton[T Resource]() *Singleton[T] {
+func NewSingleton[T Resource](log *log.Logger) *Singleton[T] {
 	return &Singleton[T]{
+		log:       log,
 		resources: make(map[string]T),
 	}
 }
@@ -22,6 +26,7 @@ func (s *Singleton[T]) GetOpen(key string, open func() (T, error)) (T, error) {
 
 	existing, ok := s.resources[key]
 	if ok {
+		s.log.DebugMsg("resource reused", "key", key)
 		return existing, nil
 	}
 
@@ -31,6 +36,7 @@ func (s *Singleton[T]) GetOpen(key string, open func() (T, error)) (T, error) {
 		return empty, err
 	}
 
+	s.log.DebugMsg("new resource", "key", key)
 	s.resources[key] = res
 
 	return res, nil
@@ -44,6 +50,7 @@ func (s *Singleton[T]) CloseUnused(isUsed func(key string) bool) error {
 		if isUsed(key) {
 			continue
 		}
+		s.log.DebugMsg("resource released", "key", key)
 		res.Close()
 		delete(s.resources, key)
 	}
@@ -56,6 +63,7 @@ func (s *Singleton[T]) Close() error {
 	defer s.lock.Unlock()
 
 	for key, res := range s.resources {
+		s.log.DebugMsg("resource released", "key", key)
 		res.Close()
 		delete(s.resources, key)
 	}
diff --git a/framework/resource/tracker.go b/framework/resource/tracker.go
index 318cf298..ae2d9796 100644
--- a/framework/resource/tracker.go
+++ b/framework/resource/tracker.go
@@ -42,6 +42,10 @@ func (t *Tracker[T]) CloseUnused(isUsed func(key string) bool) error {
 
 	return t.C.CloseUnused(func(key string) bool {
 		used := t.used[key]
-		return used && isUsed(key)
+		used = used && isUsed(key)
+		if !used {
+			delete(t.used, key)
+		}
+		return used
 	})
 }
diff --git a/internal/endpoint/smtp/smtp.go b/internal/endpoint/smtp/smtp.go
index ee433e86..8c1b9616 100644
--- a/internal/endpoint/smtp/smtp.go
+++ b/internal/endpoint/smtp/smtp.go
@@ -72,7 +72,8 @@ type Endpoint struct {
 	maxReceived         int
 	maxHeaderBytes      int64
 
-	sessionCnt atomic.Int32
+	sessionCnt      atomic.Int32
+	shutdownTimeout time.Duration
 
 	authNormalize authz.NormalizeFunc
 	authMap       module.Table
@@ -251,6 +252,7 @@ func (endp *Endpoint) setConfig(cfg *config.Map) error {
 	modconfig.Table(cfg, "auth_map", true, false, nil, &endp.saslAuth.AuthMap)
 	cfg.Duration("write_timeout", false, false, 1*time.Minute, &endp.serv.WriteTimeout)
 	cfg.Duration("read_timeout", false, false, 10*time.Minute, &endp.serv.ReadTimeout)
+	cfg.Duration("shutdown_timeout", false, false, 3*time.Minute, &endp.shutdownTimeout)
 	cfg.DataSize("max_message_size", false, false, 32*1024*1024, &endp.serv.MaxMessageBytes)
 	cfg.DataSize("max_header_size", false, false, 1*1024*1024, &endp.maxHeaderBytes)
 	cfg.Int("max_recipients", false, false, 20000, &endp.serv.MaxRecipients)
@@ -424,8 +426,13 @@ func (endp *Endpoint) ConnectionCount() int {
 }
 
 func (endp *Endpoint) Stop() error {
-	endp.serv.Close()
+	ctx, cancel := context.WithTimeout(context.Background(), endp.shutdownTimeout)
+	defer cancel()
+
+	endp.serv.Shutdown(ctx)
+
 	endp.listenersWg.Wait()
+
 	return nil
 }
 
diff --git a/maddy.go b/maddy.go
index e0a33e1d..c60360e2 100644
--- a/maddy.go
+++ b/maddy.go
@@ -26,6 +26,7 @@ import (
 	"path/filepath"
 	"runtime"
 	"runtime/debug"
+	"sync"
 
 	"github.com/caddyserver/certmagic"
 	parser "github.com/foxcpp/maddy/framework/cfgparser"
@@ -206,6 +207,8 @@ func Run(c *cli.Context) error {
 	defer log.DefaultLogger.Out.Close()
 	defer hooks.RunHooks(hooks.EventShutdown)
 
+	hooks.AddHook(hooks.EventShutdown, netresource.CloseAllListeners)
+
 	if err := moduleMain(c.Path("config")); err != nil {
 		systemdStatusErr(err)
 		return cli.Exit(err.Error(), 1)
@@ -395,24 +398,29 @@ func moduleMain(configPath string) error {
 	}
 	c.DefaultLogger.Msg("server started", "version", Version)
 
-	systemdStatus(SDReady, "Listening for incoming connections...")
+	systemdStatus(SDReady, "Configuration running.")
+	asyncStopWg := sync.WaitGroup{} // Some containers might still be waiting on moduleStop
 	for handleSignals() {
-		systemdStatus(SDReloading, "Reloading state...")
 		hooks.RunHooks(hooks.EventReload)
 
-		c = moduleReload(c, configPath)
+		c = moduleReload(c, configPath, &asyncStopWg)
 	}
 
 	c.DefaultLogger.Msg("server stopping...")
-	systemdStatus(SDStopping, "Waiting for running transactions to complete...")
 
+	systemdStatus(SDStopping, "Waiting for old configuration to stop...")
+	asyncStopWg.Wait()
+
+	systemdStatus(SDStopping, "Waiting for current configuration to stop...")
 	moduleStop(c)
 	c.DefaultLogger.Msg("server stopped")
+
 	return nil
 }
 
-func moduleReload(oldContainer *container.C, configPath string) *container.C {
+func moduleReload(oldContainer *container.C, configPath string, asyncStopWg *sync.WaitGroup) *container.C {
 	oldContainer.DefaultLogger.Msg("reloading server...")
+	systemdStatus(SDReloading, "Reloading server...")
 
 	oldContainer.DefaultLogger.Msg("loading new configuration...")
 	newContainer, err := moduleConfigure(configPath)
@@ -430,12 +438,22 @@ func moduleReload(oldContainer *container.C, configPath string) *container.C {
 		container.Global = oldContainer
 		return oldContainer
 	}
-	netresource.CloseUnusedListeners()
 
 	newContainer.DefaultLogger.Msg("server started", "version", Version)
-	oldContainer.DefaultLogger.Msg("stopping server")
-	moduleStop(oldContainer)
-	oldContainer.DefaultLogger.Msg("server stopped")
+
+	systemdStatus(SDReloading, "New configuration running. Waiting for old connections and transactions to finish...")
+
+	asyncStopWg.Add(1)
+	go func() {
+		defer asyncStopWg.Done()
+		defer netresource.CloseUnusedListeners()
+
+		oldContainer.DefaultLogger.Msg("stopping old server")
+		moduleStop(oldContainer)
+		oldContainer.DefaultLogger.Msg("old server stopped")
+
+		systemdStatus(SDReloading, "Configuration running.")
+	}()
 
 	return newContainer
 }

From 95ba6bf1fc71d12c2fed0f1eadcb5fc237d2a5c8 Mon Sep 17 00:00:00 2001
From: "fox.cpp" <fox.cpp@disroot.org>
Date: Thu, 30 Jan 2025 03:52:41 +0300
Subject: [PATCH 5/6] cli/cli: Fix panic on module access

---
 internal/cli/ctl/moduleinit.go | 1 +
 internal/table/sql_query.go    | 4 ++--
 2 files changed, 3 insertions(+), 2 deletions(-)

diff --git a/internal/cli/ctl/moduleinit.go b/internal/cli/ctl/moduleinit.go
index ef58d7b4..1129a516 100644
--- a/internal/cli/ctl/moduleinit.go
+++ b/internal/cli/ctl/moduleinit.go
@@ -44,6 +44,7 @@ func getCfgBlockModule(ctx *cli.Context) (*container.C, module.Module, error) {
 	}
 
 	c := container.New()
+	container.Global = c
 
 	cfg, err := maddy.ReadConfig(cfgPath)
 	if err != nil {
diff --git a/internal/table/sql_query.go b/internal/table/sql_query.go
index 96bce8f0..0fe5c64f 100644
--- a/internal/table/sql_query.go
+++ b/internal/table/sql_query.go
@@ -133,11 +133,11 @@ func (s *SQL) Configure(inlineArgs []string, cfg *config.Map) error {
 		return nil
 	}
 
-	return nil
+	return s.prepare()
 }
 
 func (s *SQL) Start() error {
-	return s.prepare()
+	return nil
 }
 
 func (s *SQL) Stop() error {

From 4a5f379832d8c659ac6f4749e6df0400603a1315 Mon Sep 17 00:00:00 2001
From: John Weldon <johnweldon4@gmail.com>
Date: Sat, 22 Feb 2025 13:47:14 -0700
Subject: [PATCH 6/6] framework/log: Fix Logger usages in tests

---
 internal/storage/blob/test_blob.go           | 3 ++-
 internal/target/remote/dane_delivery_test.go | 3 ++-
 internal/target/remote/mxauth_test.go        | 6 ++++--
 3 files changed, 8 insertions(+), 4 deletions(-)

diff --git a/internal/storage/blob/test_blob.go b/internal/storage/blob/test_blob.go
index 9672efea..8f3d0bcb 100644
--- a/internal/storage/blob/test_blob.go
+++ b/internal/storage/blob/test_blob.go
@@ -39,10 +39,11 @@ func TestStore(t *testing.T, newStore func() module.BlobStore, cleanStore func(m
 		prng := rand.New(randSrc)
 		store := newStore()
 
+		l := testutils.Logger(t, "imapsql")
 		b, err := imapsql.New("sqlite3", ":memory:",
 			imapsql2.ExtBlobStore{Base: store}, imapsql.Opts{
 				PRNG: prng,
-				Log:  testutils.Logger(t, "imapsql"),
+				Log:  &l,
 			},
 		)
 		if err != nil {
diff --git a/internal/target/remote/dane_delivery_test.go b/internal/target/remote/dane_delivery_test.go
index 2b921c7d..377f7cfc 100644
--- a/internal/target/remote/dane_delivery_test.go
+++ b/internal/target/remote/dane_delivery_test.go
@@ -32,7 +32,8 @@ import (
 )
 
 func targetWithExtResolver(t *testing.T, zones map[string]mockdns.Zone) (*mockdns.Server, *Target) {
-	dnsSrv, err := mockdns.NewServerWithLogger(zones, testutils.Logger(t, "mockdns"), false)
+	l := testutils.Logger(t, "mockdns")
+	dnsSrv, err := mockdns.NewServerWithLogger(zones, &l, false)
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/internal/target/remote/mxauth_test.go b/internal/target/remote/mxauth_test.go
index e332bdb4..978567c5 100644
--- a/internal/target/remote/mxauth_test.go
+++ b/internal/target/remote/mxauth_test.go
@@ -303,7 +303,8 @@ func TestRemoteDelivery_AuthMX_DNSSEC(t *testing.T) {
 		},
 	}
 
-	dnsSrv, err := mockdns.NewServerWithLogger(zones, testutils.Logger(t, "mockdns"), false)
+	l := testutils.Logger(t, "mockdns")
+	dnsSrv, err := mockdns.NewServerWithLogger(zones, &l, false)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -342,7 +343,8 @@ func TestRemoteDelivery_AuthMX_DNSSEC_Fail(t *testing.T) {
 		},
 	}
 
-	dnsSrv, err := mockdns.NewServerWithLogger(zones, testutils.Logger(t, "mockdns"), false)
+	l := testutils.Logger(t, "mockdns")
+	dnsSrv, err := mockdns.NewServerWithLogger(zones, &l, false)
 	if err != nil {
 		t.Fatal(err)
 	}
