diff --git a/cmd/access.go b/cmd/access.go
index e70a68b7d51f1ee71374c8e42125cacdbb4817a0..b36dc38b6f3774309c292bc17ec1520b50f945a9 100644
--- a/cmd/access.go
+++ b/cmd/access.go
@@ -19,7 +19,7 @@ const (
 )
 
 var flagsAccess = append(
-	userCommandFlags(),
+	flagsUser,
 	&cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"},
 )
 
@@ -28,7 +28,7 @@ var cmdAccess = &cli.Command{
 	Usage:     "Grant/revoke access to a topic, or show access",
 	UsageText: "ntfy access [USERNAME [TOPIC [PERMISSION]]]",
 	Flags:     flagsAccess,
-	Before:    initConfigFileInputSourceFunc("config", flagsAccess),
+	Before:    initLogFunc(initConfigFileInputSourceFunc("config", flagsAccess)),
 	Action:    execUserAccess,
 	Category:  categoryServer,
 	Description: `Manage the access control list for the ntfy server.
diff --git a/cmd/app.go b/cmd/app.go
index 5a0b426fe0a35e1827c47cc72d2e9840a0ed9a26..89634a8b38fd6955047717b1627f2a78c3da33e7 100644
--- a/cmd/app.go
+++ b/cmd/app.go
@@ -3,6 +3,7 @@ package cmd
 
 import (
 	"github.com/urfave/cli/v2"
+	"heckel.io/ntfy/log"
 	"os"
 )
 
@@ -13,6 +14,11 @@ const (
 
 var commands = make([]*cli.Command, 0)
 
+var flagsDefault = []cli.Flag{
+	&cli.BoolFlag{Name: "debug", Aliases: []string{"d"}, EnvVars: []string{"NTFY_DEBUG"}, Usage: "enable debug logging"},
+	&cli.StringFlag{Name: "log-level", Aliases: []string{"log_level"}, Value: log.InfoLevel.String(), EnvVars: []string{"NTFY_LOG_LEVEL"}, Usage: "set log level"},
+}
+
 // New creates a new CLI application
 func New() *cli.App {
 	return &cli.App{
@@ -25,5 +31,23 @@ func New() *cli.App {
 		Writer:                 os.Stdout,
 		ErrWriter:              os.Stderr,
 		Commands:               commands,
+		Flags:                  flagsDefault,
+		Before:                 initLogFunc(nil),
+	}
+}
+
+func initLogFunc(next cli.BeforeFunc) cli.BeforeFunc {
+	return func(c *cli.Context) error {
+		if c.Bool("debug") {
+			log.SetLevel(log.DebugLevel)
+		} else {
+			log.SetLevel(log.ToLevel(c.String("log-level")))
+		}
+		if next != nil {
+			if err := next(c); err != nil {
+				return err
+			}
+		}
+		return nil
 	}
 }
diff --git a/cmd/publish.go b/cmd/publish.go
index 06f8d962c43b612f40e51a15c24a5ca5f35d2d1c..51d30b6a1c6ab584695511f84959c979d1c473ff 100644
--- a/cmd/publish.go
+++ b/cmd/publish.go
@@ -16,31 +16,35 @@ func init() {
 	commands = append(commands, cmdPublish)
 }
 
+var flagsPublish = append(
+	flagsDefault,
+	&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
+	&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
+	&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
+	&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"},
+	&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"},
+	&cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"},
+	&cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"},
+	&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
+	&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
+	&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
+	&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
+	&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
+	&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
+	&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
+	&cli.BoolFlag{Name: "env-topic", Aliases: []string{"P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
+	&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do not print message"},
+)
+
 var cmdPublish = &cli.Command{
 	Name:      "publish",
 	Aliases:   []string{"pub", "send", "trigger"},
 	Usage:     "Send message via a ntfy server",
-	UsageText: "ntfy send [OPTIONS..] TOPIC [MESSAGE]\n   NTFY_TOPIC=.. ntfy send [OPTIONS..] -P [MESSAGE]",
+	UsageText: "ntfy publish [OPTIONS..] TOPIC [MESSAGE]\nNTFY_TOPIC=.. ntfy publish [OPTIONS..] -P [MESSAGE]",
 	Action:    execPublish,
 	Category:  categoryClient,
-	Flags: []cli.Flag{
-		&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
-		&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
-		&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
-		&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"},
-		&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"},
-		&cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"},
-		&cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"},
-		&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
-		&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
-		&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
-		&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
-		&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
-		&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
-		&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
-		&cli.BoolFlag{Name: "env-topic", Aliases: []string{"P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
-		&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do print message"},
-	},
+	Flags:     flagsPublish,
+	Before:    initLogFunc(nil),
 	Description: `Publish a message to a ntfy server.
 
 Examples:
diff --git a/cmd/serve.go b/cmd/serve.go
index 630889d068b4c6badb79b21e0d234b3dbd6e3ebb..df1f5798d254650cc0bc3ca137123089e066d75c 100644
--- a/cmd/serve.go
+++ b/cmd/serve.go
@@ -5,7 +5,7 @@ package cmd
 import (
 	"errors"
 	"fmt"
-	"log"
+	"heckel.io/ntfy/log"
 	"math"
 	"net"
 	"strings"
@@ -21,7 +21,8 @@ func init() {
 	commands = append(commands, cmdServe)
 }
 
-var flagsServe = []cli.Flag{
+var flagsServe = append(
+	flagsDefault,
 	&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"},
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
@@ -59,7 +60,7 @@ var flagsServe = []cli.Flag{
 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
 	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
 	altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
-}
+)
 
 var cmdServe = &cli.Command{
 	Name:      "serve",
@@ -68,7 +69,7 @@ var cmdServe = &cli.Command{
 	Action:    execServe,
 	Category:  categoryServer,
 	Flags:     flagsServe,
-	Before:    initConfigFileInputSourceFunc("config", flagsServe),
+	Before:    initLogFunc(initConfigFileInputSourceFunc("config", flagsServe)),
 	Description: `Run the ntfy server and listen for incoming requests
 
 The command will load the configuration from /etc/ntfy/server.yml. Config options can 
@@ -192,7 +193,7 @@ func execServe(c *cli.Context) error {
 	for _, host := range visitorRequestLimitExemptHosts {
 		ips, err := net.LookupIP(host)
 		if err != nil {
-			log.Printf("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error())
+			log.Warn("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error())
 			continue
 		}
 		for _, ip := range ips {
@@ -242,12 +243,12 @@ func execServe(c *cli.Context) error {
 	conf.EnableWeb = enableWeb
 	s, err := server.New(conf)
 	if err != nil {
-		log.Fatalln(err)
+		log.Fatal(err)
 	}
 	if err := s.Run(); err != nil {
-		log.Fatalln(err)
+		log.Fatal(err)
 	}
-	log.Printf("Exiting.")
+	log.Info("Exiting.")
 	return nil
 }
 
diff --git a/cmd/subscribe.go b/cmd/subscribe.go
index 97f7e41073cb9eb7b7004229119b4fc465a04f82..618cdb9b6671d04e897c187a016adc03bfbbc2da 100644
--- a/cmd/subscribe.go
+++ b/cmd/subscribe.go
@@ -24,6 +24,17 @@ const (
 	clientUserConfigFileWindowsRelative = "ntfy\\client.yml"
 )
 
+var flagsSubscribe = append(
+	flagsDefault,
+	&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
+	&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
+	&cli.StringFlag{Name: "user", Aliases: []string{"u"}, Usage: "username[:password] used to auth against the server"},
+	&cli.BoolFlag{Name: "from-config", Aliases: []string{"C"}, Usage: "read subscriptions from config file (service mode)"},
+	&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
+	&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
+	&cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "print verbose output"},
+)
+
 var cmdSubscribe = &cli.Command{
 	Name:      "subscribe",
 	Aliases:   []string{"sub"},
@@ -31,15 +42,8 @@ var cmdSubscribe = &cli.Command{
 	UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]",
 	Action:    execSubscribe,
 	Category:  categoryClient,
-	Flags: []cli.Flag{
-		&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
-		&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
-		&cli.StringFlag{Name: "user", Aliases: []string{"u"}, Usage: "username[:password] used to auth against the server"},
-		&cli.BoolFlag{Name: "from-config", Aliases: []string{"C"}, Usage: "read subscriptions from config file (service mode)"},
-		&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
-		&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
-		&cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "print verbose output"},
-	},
+	Flags:     flagsSubscribe,
+	Before:    initLogFunc(nil),
 	Description: `Subscribe to a topic from a ntfy server, and either print or execute a command for 
 every arriving message. There are 3 modes in which the command can be run:
 
diff --git a/cmd/user.go b/cmd/user.go
index 5ccc5b15875bf8ca7b0ebf2942a54c602d932555..921aeda1483e2596a5defb531c8a6aaafc31d8d0 100644
--- a/cmd/user.go
+++ b/cmd/user.go
@@ -17,14 +17,19 @@ func init() {
 	commands = append(commands, cmdUser)
 }
 
-var flagsUser = userCommandFlags()
+var flagsUser = append(
+	flagsDefault,
+	&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"},
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
+)
 
 var cmdUser = &cli.Command{
 	Name:      "user",
 	Usage:     "Manage/show users",
 	UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...",
 	Flags:     flagsUser,
-	Before:    initConfigFileInputSourceFunc("config", flagsUser),
+	Before:    initLogFunc(initConfigFileInputSourceFunc("config", flagsUser)),
 	Category:  categoryServer,
 	Subcommands: []*cli.Command{
 		{
@@ -269,11 +274,3 @@ func readPasswordAndConfirm(c *cli.Context) (string, error) {
 	}
 	return string(password), nil
 }
-
-func userCommandFlags() []cli.Flag {
-	return []cli.Flag{
-		&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"},
-		altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
-		altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
-	}
-}
diff --git a/log/log.go b/log/log.go
new file mode 100644
index 0000000000000000000000000000000000000000..8c13450813f87bf6070e8697313da5562c7fc6f7
--- /dev/null
+++ b/log/log.go
@@ -0,0 +1,79 @@
+package log
+
+import (
+	"log"
+	"strings"
+)
+
+type Level int
+
+const (
+	DebugLevel Level = iota
+	InfoLevel
+	WarnLevel
+	ErrorLevel
+)
+
+func (l Level) String() string {
+	switch l {
+	case DebugLevel:
+		return "DEBUG"
+	case InfoLevel:
+		return "INFO"
+	case WarnLevel:
+		return "WARN"
+	case ErrorLevel:
+		return "ERROR"
+	}
+	return "unknown"
+}
+
+var (
+	level = InfoLevel
+)
+
+func Debug(message string, v ...interface{}) {
+	logIf(DebugLevel, message, v...)
+}
+
+func Info(message string, v ...interface{}) {
+	logIf(InfoLevel, message, v...)
+}
+
+func Warn(message string, v ...interface{}) {
+	logIf(WarnLevel, message, v...)
+}
+
+func Error(message string, v ...interface{}) {
+	logIf(ErrorLevel, message, v...)
+}
+
+func Fatal(v ...interface{}) {
+	log.Fatalln(v...)
+}
+
+func SetLevel(newLevel Level) {
+	level = newLevel
+}
+
+func ToLevel(s string) Level {
+	switch strings.ToLower(s) {
+	case "debug":
+		return DebugLevel
+	case "info":
+		return InfoLevel
+	case "warn", "warning":
+		return WarnLevel
+	case "error":
+		return ErrorLevel
+	default:
+		log.Fatalf("unknown log level: %s", s)
+		return 0
+	}
+}
+
+func logIf(l Level, message string, v ...interface{}) {
+	if level <= l {
+		log.Printf(l.String()+" "+message, v...)
+	}
+}
diff --git a/server/server.go b/server/server.go
index 86ed753957e5d89192b027b19a24ec78ef28299a..fe5a661cf4ea4d64963c4a0da6c47426984914ec 100644
--- a/server/server.go
+++ b/server/server.go
@@ -9,8 +9,8 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"heckel.io/ntfy/log"
 	"io"
-	"log"
 	"net"
 	"net/http"
 	"net/http/httptest"
@@ -181,7 +181,7 @@ func (s *Server) Run() error {
 	if s.config.SMTPServerListen != "" {
 		listenStr += fmt.Sprintf(" %s[smtp]", s.config.SMTPServerListen)
 	}
-	log.Printf("Listening on%s", listenStr)
+	log.Info("Listening on%s", listenStr)
 	mux := http.NewServeMux()
 	mux.HandleFunc("/", s.handle)
 	errChan := make(chan error)
@@ -221,7 +221,7 @@ func (s *Server) Run() error {
 	}
 	s.mu.Unlock()
 	go s.runManager()
-	go s.runAtSender()
+	go s.runDelaySender()
 	go s.runFirebaseKeepaliver()
 
 	return <-errChan
@@ -248,16 +248,18 @@ func (s *Server) Stop() {
 
 func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
 	v := s.visitor(r)
+	log.Debug("[%s] %s %s", v.ip, r.Method, r.URL.Path)
+
 	if err := s.handleInternal(w, r, v); err != nil {
 		if websocket.IsWebSocketUpgrade(r) {
-			log.Printf("[%s] WS %s %s - %s", v.ip, r.Method, r.URL.Path, err.Error())
+			log.Info("[%s] WS %s %s - %s", v.ip, r.Method, r.URL.Path, err.Error())
 			return // Do not attempt to write to upgraded connection
 		}
 		httpErr, ok := err.(*errHTTP)
 		if !ok {
 			httpErr = errHTTPInternalError
 		}
-		log.Printf("[%s] HTTP %s %s - %d - %d - %s", v.ip, r.Method, r.URL.Path, httpErr.HTTPCode, httpErr.Code, err.Error())
+		log.Info("[%s] HTTP %s %s - %d - %d - %s", v.ip, r.Method, r.URL.Path, httpErr.HTTPCode, httpErr.Code, err.Error())
 		w.Header().Set("Content-Type", "application/json")
 		w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
 		w.WriteHeader(httpErr.HTTPCode)
@@ -434,6 +436,8 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
 		m.Message = emptyMessageBody
 	}
 	delayed := m.Time > time.Now().Unix()
+	log.Debug("[%s] %s %s: ev=%s, body=%d bytes, delayed=%t, fb=%t, cache=%t, up=%t, email=%s",
+		v.ip, r.Method, r.URL.Path, m.Event, len(body.PeekedBytes), delayed, firebase, cache, unifiedpush, email)
 	if !delayed {
 		if err := t.Publish(m); err != nil {
 			return err
@@ -466,13 +470,13 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
 
 func (s *Server) sendToFirebase(v *visitor, m *message) {
 	if err := s.firebase(m); err != nil {
-		log.Printf("[%s] FB - Unable to publish to Firebase: %v", v.ip, err.Error())
+		log.Warn("[%s] FB - Unable to publish to Firebase: %v", v.ip, err.Error())
 	}
 }
 
 func (s *Server) sendEmail(v *visitor, m *message, email string) {
 	if err := s.mailer.Send(v.ip, email, m); err != nil {
-		log.Printf("[%s] MAIL - Unable to send email: %v", v.ip, err.Error())
+		log.Warn("[%s] MAIL - Unable to send email: %v", v.ip, err.Error())
 	}
 }
 
@@ -482,16 +486,16 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
 	forwardURL := fmt.Sprintf("%s/%s", s.config.UpstreamBaseURL, topicHash)
 	req, err := http.NewRequest("POST", forwardURL, strings.NewReader(""))
 	if err != nil {
-		log.Printf("[%s] FWD - Unable to forward poll request: %v", v.ip, err.Error())
+		log.Warn("[%s] FWD - Unable to forward poll request: %v", v.ip, err.Error())
 		return
 	}
 	req.Header.Set("X-Poll-ID", m.ID)
 	response, err := http.DefaultClient.Do(req)
 	if err != nil {
-		log.Printf("[%s] FWD - Unable to forward poll request: %v", v.ip, err.Error())
+		log.Warn("[%s] FWD - Unable to forward poll request: %v", v.ip, err.Error())
 		return
 	} else if response.StatusCode != http.StatusOK {
-		log.Printf("[%s] FWD - Unable to forward poll request, unexpected status: %d", v.ip, response.StatusCode)
+		log.Warn("[%s] FWD - Unable to forward poll request, unexpected status: %d", v.ip, response.StatusCode)
 		return
 	}
 }
@@ -1015,17 +1019,17 @@ func (s *Server) updateStatsAndPrune() {
 		ids, err := s.messageCache.AttachmentsExpired()
 		if err == nil {
 			if err := s.fileCache.Remove(ids...); err != nil {
-				log.Printf("error while deleting attachments: %s", err.Error())
+				log.Warn("Error deleting attachments: %s", err.Error())
 			}
 		} else {
-			log.Printf("error retrieving expired attachments: %s", err.Error())
+			log.Warn("Error retrieving expired attachments: %s", err.Error())
 		}
 	}
 
 	// Prune message cache
 	olderThan := time.Now().Add(-1 * s.config.CacheDuration)
 	if err := s.messageCache.Prune(olderThan); err != nil {
-		log.Printf("error pruning cache: %s", err.Error())
+		log.Warn("Error pruning cache: %s", err.Error())
 	}
 
 	// Prune old topics, remove subscriptions without subscribers
@@ -1034,7 +1038,7 @@ func (s *Server) updateStatsAndPrune() {
 		subs := t.Subscribers()
 		msgs, err := s.messageCache.MessageCount(t.ID)
 		if err != nil {
-			log.Printf("cannot get stats for topic %s: %s", t.ID, err.Error())
+			log.Warn("Cannot get stats for topic %s: %s", t.ID, err.Error())
 			continue
 		}
 		if msgs == 0 && subs == 0 {
@@ -1052,7 +1056,7 @@ func (s *Server) updateStatsAndPrune() {
 	}
 
 	// Print stats
-	log.Printf("Stats: %d message(s) published, %d in cache, %d successful mails, %d failed, %d topic(s) active, %d subscriber(s), %d visitor(s)",
+	log.Info("Stats: %d message(s) published, %d in cache, %d successful mails, %d failed, %d topic(s) active, %d subscriber(s), %d visitor(s)",
 		s.messages, messages, mailSuccess, mailFailure, len(s.topics), subscribers, len(s.visitors))
 }
 
@@ -1096,12 +1100,12 @@ func (s *Server) runManager() {
 	}
 }
 
-func (s *Server) runAtSender() {
+func (s *Server) runDelaySender() {
 	for {
 		select {
 		case <-time.After(s.config.AtSenderInterval):
 			if err := s.sendDelayedMessages(); err != nil {
-				log.Printf("error sending scheduled messages: %s", err.Error())
+				log.Warn("error sending scheduled messages: %s", err.Error())
 			}
 		case <-s.closeChan:
 			return
@@ -1117,11 +1121,11 @@ func (s *Server) runFirebaseKeepaliver() {
 		select {
 		case <-time.After(s.config.FirebaseKeepaliveInterval):
 			if err := s.firebase(newKeepaliveMessage(firebaseControlTopic)); err != nil {
-				log.Printf("error sending Firebase keepalive message to %s: %s", firebaseControlTopic, err.Error())
+				log.Info("error sending Firebase keepalive message to %s: %s", firebaseControlTopic, err.Error())
 			}
 		case <-time.After(s.config.FirebasePollInterval):
 			if err := s.firebase(newKeepaliveMessage(firebasePollTopic)); err != nil {
-				log.Printf("error sending Firebase keepalive message to %s: %s", firebasePollTopic, err.Error())
+				log.Info("error sending Firebase keepalive message to %s: %s", firebasePollTopic, err.Error())
 			}
 		case <-s.closeChan:
 			return
@@ -1140,12 +1144,12 @@ func (s *Server) sendDelayedMessages() error {
 		t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published
 		if ok {
 			if err := t.Publish(m); err != nil {
-				log.Printf("unable to publish message %s to topic %s: %v", m.ID, m.Topic, err.Error())
+				log.Info("unable to publish message %s to topic %s: %v", m.ID, m.Topic, err.Error())
 			}
 		}
 		if s.firebase != nil { // Firebase subscribers may not show up in topics map
 			if err := s.firebase(m); err != nil {
-				log.Printf("unable to publish to Firebase: %v", err.Error())
+				log.Info("unable to publish to Firebase: %v", err.Error())
 			}
 		}
 		if err := s.messageCache.MarkPublished(m); err != nil {
@@ -1252,13 +1256,13 @@ func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc {
 		username, password, ok := extractUserPass(r)
 		if ok {
 			if user, err = s.auth.Authenticate(username, password); err != nil {
-				log.Printf("authentication failed: %s", err.Error())
+				log.Info("authentication failed: %s", err.Error())
 				return errHTTPUnauthorized
 			}
 		}
 		for _, t := range topics {
 			if err := s.auth.Authorize(user, t.ID, perm); err != nil {
-				log.Printf("unauthorized: %s", err.Error())
+				log.Info("unauthorized: %s", err.Error())
 				return errHTTPForbidden
 			}
 		}