Repository: favonia/cloudflare-ddns
Pull request #69 (2021-08-10 00:28:43)

 refactor: simplify config and refactor pp

codecov[bot]: # [Codecov](https://codecov.io/gh/favonia/cloudflare-ddns/pull/69?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) Report
> Merging [#69](https://codecov.io/gh/favonia/cloudflare-ddns/pull/69?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) (7195f57) into [main](https://codecov.io/gh/favonia/cloudflare-ddns/commit/c7a6a26c13a56499677222059300f267d7a633a6?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) (c7a6a26) will **increase** coverage by `9.39%`.
> The diff coverage is `20.00%`.

[![Impacted file tree graph](https://codecov.io/gh/favonia/cloudflare-ddns/pull/69/graphs/tree.svg?width=650&height=150&src=pr&token=90Cwhy7OkI&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia)](https://codecov.io/gh/favonia/cloudflare-ddns/pull/69?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia)

```diff
@@            Coverage Diff             @@
##             main      #69      +/-   ##
==========================================
+ Coverage   60.25%   69.64%   +9.39%     
==========================================
  Files          21       21              
  Lines        1175     1163      -12     
==========================================
+ Hits          708      810     +102     
+ Misses        463      348     -115     
- Partials        4        5       +1     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `69.64% <20.00%> (+9.39%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/favonia/cloudflare-ddns/pull/69?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | Coverage Δ | |
|---|---|---|
| [cmd/ip.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/69/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-Y21kL2lwLmdv) | `0.00% <0.00%> (ø)` | |
| [internal/config/config.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/69/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvY29uZmlnL2NvbmZpZy5nbw==) | `56.04% <0.00%> (+56.04%)` | :arrow_up: |
| [internal/detector/local.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/69/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvZGV0ZWN0b3IvbG9jYWwuZ28=) | `100.00% <100.00%> (ø)` | |
| [internal/ipnet/ipnet.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/69/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvaXBuZXQvaXBuZXQuZ28=) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/favonia/cloudflare-ddns/pull/69?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/favonia/cloudflare-ddns/pull/69?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia). Last update [c7a6a26...7195f57](https://codecov.io/gh/favonia/cloudflare-ddns/pull/69?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).


Patch:
diff --git a/README.markdown b/README.markdown
index 9135f64..efc9c82 100644
--- a/README.markdown
+++ b/README.markdown
@@ -44,7 +44,7 @@ By default, public IP addresses are obtained using [Cloudflare via DNS-over-HTTP
 The updater honors `PGID` and `PUID` and will drop Linux capabilities (divided superuser privileges).
 </details>
 
-<details><summary>🔌 The source code depends on five external libraries (outside the Go project).</summary>
+<details><summary>🔌 The source code depends on six external libraries (outside the Go project).</summary>
 
 - [cap](https://sites.google.com/site/fullycapable):\
   Manipulation of Linux capabilities.
@@ -54,7 +54,9 @@ The updater honors `PGID` and `PUID` and will drop Linux capabilities (divided s
   Parsing of Cron expressions.
 - [go-cache](https://github.com/patrickmn/go-cache):\
   Essentially `map[string]interface{}` with expiration times.
-- [testify](https://github.com/stretchr/testify) (only for testing):\
+- [mock](https://github.com/golang/mock) (for testing only):\
+  A comprehensive, semi-official framework for mocking.
+- [testify](https://github.com/stretchr/testify) (for testing only):\
   A comprehensive tool set for testing Go programs.
 </details>
 
diff --git a/cmd/ddns.go b/cmd/ddns.go
index 7530948..63d915c 100644
--- a/cmd/ddns.go
+++ b/cmd/ddns.go
@@ -30,49 +30,56 @@ func signalWait(signal chan os.Signal, d time.Duration) (os.Signal, bool) {
 
 var Version string //nolint:gochecknoglobals
 
-func welcome() {
+func welcome(ppfmt pp.PP) {
 	if Version == "" {
-		pp.TopPrintf(pp.EmojiStar, "Cloudflare DDNS")
+		ppfmt.Noticef(pp.EmojiStar, "Cloudflare DDNS")
 		return
 	}
 
-	pp.TopPrintf(pp.EmojiStar, "Cloudflare DDNS (%s)", Version)
+	ppfmt.Noticef(pp.EmojiStar, "Cloudflare DDNS (%s)", Version)
 }
 
-func initConfig(ctx context.Context) (*config.Config, api.Handle) {
+func initConfig(ctx context.Context, ppfmt pp.PP) (*config.Config, api.Handle) {
 	// reading the config
 	c := config.Default()
-	if !c.ReadEnv(pp.NoIndent) {
-		pp.TopPrintf(pp.EmojiBye, "Bye!")
+	if !c.ReadEnv(ppfmt) {
+		ppfmt.Noticef(pp.EmojiBye, "Bye!")
 		os.Exit(1)
 	}
-	if !c.Normalize(pp.NoIndent) {
-		pp.TopPrintf(pp.EmojiBye, "Bye!")
+	if !c.Normalize(ppfmt) {
+		ppfmt.Noticef(pp.EmojiBye, "Bye!")
 		os.Exit(1)
 	}
 
-	if !c.Quiet {
-		config.PrintConfig(pp.NoIndent, c)
-	}
+	config.PrintConfig(ppfmt, c)
 
 	// getting the handler
-	h, ok := c.Auth.New(ctx, pp.NoIndent, c.CacheExpiration)
+	h, ok := c.Auth.New(ctx, ppfmt, c.CacheExpiration)
 	if !ok {
-		pp.TopPrintf(pp.EmojiBye, "Bye!")
+		ppfmt.Noticef(pp.EmojiBye, "Bye!")
 		os.Exit(1)
 	}
 
 	return c, h
 }
 
-func main() { //nolint:funlen,gocognit,cyclop
-	welcome()
+func main() { //nolint:funlen,cyclop
+	ppfmt := pp.New(os.Stdout)
+	if !config.ReadQuiet("QUIET", &ppfmt) {
+		ppfmt.Noticef(pp.EmojiUserError, "Bye!")
+		return
+	}
+	if !ppfmt.IsEnabledFor(pp.Info) {
+		ppfmt.Noticef(pp.EmojiMute, "Quiet mode enabled")
+	}
+
+	welcome(ppfmt)
 
 	// dropping the superuser privilege
-	dropPriviledges(pp.NoIndent)
+	dropPriviledges(ppfmt)
 
 	// printing the current privileges
-	printPriviledges(pp.NoIndent)
+	printPriviledges(ppfmt)
 
 	// catching SIGINT and SIGTERM
 	chanSignal := make(chan os.Signal, 1)
@@ -82,43 +89,41 @@ func main() { //nolint:funlen,gocognit,cyclop
 	ctx := context.Background()
 
 	// reading the config
-	c, h := initConfig(ctx)
+	c, h := initConfig(ctx, ppfmt)
 
 	first := true
 mainLoop:
 	for {
 		next := c.UpdateCron.Next()
 		if !first || c.UpdateOnStart {
-			updateIPs(ctx, pp.NoIndent, c, h)
+			updateIPs(ctx, ppfmt, c, h)
 		}
 		first = false
 
 		if next.IsZero() {
 			if c.DeleteOnStop {
-				pp.TopPrintf(pp.EmojiUserError, "No scheduled updates in near future. Deleting all managed records . . .")
-				clearIPs(ctx, pp.NoIndent, c, h)
-				pp.TopPrintf(pp.EmojiBye, "Done now. Bye!")
+				ppfmt.Errorf(pp.EmojiUserError, "No scheduled updates in near future. Deleting all managed records . . .")
+				clearIPs(ctx, ppfmt, c, h)
+				ppfmt.Noticef(pp.EmojiBye, "Done now. Bye!")
 			} else {
-				pp.TopPrintf(pp.EmojiUserError, "No scheduled updates in near future.")
-				pp.TopPrintf(pp.EmojiBye, "Bye!")
+				ppfmt.Errorf(pp.EmojiUserError, "No scheduled updates in near future")
+				ppfmt.Noticef(pp.EmojiBye, "Bye!")
 			}
 
 			break mainLoop
 		}
 
 		interval := time.Until(next)
-		if !c.Quiet {
-			switch {
-			case interval < -IntervalLargeGap:
-				pp.TopPrintf(pp.EmojiNow, "Checking the IP addresses now (running behind by %v) . . .",
-					-interval.Round(IntervalUnit))
-			case interval < IntervalUnit:
-				pp.TopPrintf(pp.EmojiNow, "Checking the IP addresses now . . .")
-			case interval < IntervalLargeGap:
-				pp.TopPrintf(pp.EmojiNow, "Checking the IP addresses in less than %v . . .", IntervalLargeGap)
-			default:
-				pp.TopPrintf(pp.EmojiAlarm, "Checking the IP addresses in about %v . . .", interval.Round(IntervalUnit))
-			}
+		switch {
+		case interval < -IntervalLargeGap:
+			ppfmt.Infof(pp.EmojiNow, "Checking the IP addresses now (running behind by %v) . . .",
+				-interval.Round(IntervalUnit))
+		case interval < IntervalUnit:
+			ppfmt.Infof(pp.EmojiNow, "Checking the IP addresses now . . .")
+		case interval < IntervalLargeGap:
+			ppfmt.Infof(pp.EmojiNow, "Checking the IP addresses in less than %v . . .", IntervalLargeGap)
+		default:
+			ppfmt.Infof(pp.EmojiAlarm, "Checking the IP addresses in about %v . . .", interval.Round(IntervalUnit))
 		}
 
 		if sig, ok := signalWait(chanSignal, interval); !ok {
@@ -126,27 +131,27 @@ mainLoop:
 		} else {
 			switch sig.(syscall.Signal) {
 			case syscall.SIGHUP:
-				pp.TopPrintf(pp.EmojiSignal, "Caught signal: %v.", sig)
+				ppfmt.Noticef(pp.EmojiSignal, "Caught signal: %v", sig)
 				h.FlushCache()
 
-				pp.TopPrintf(pp.EmojiNow, "Restarting . . .")
-				c, h = initConfig(ctx)
+				ppfmt.Noticef(pp.EmojiNow, "Restarting . . .")
+				c, h = initConfig(ctx, ppfmt)
 				continue mainLoop
 
 			case syscall.SIGINT, syscall.SIGTERM:
 				if c.DeleteOnStop {
-					pp.TopPrintf(pp.EmojiSignal, "Caught signal: %v. Deleting all managed records . . .", sig)
-					clearIPs(ctx, pp.NoIndent, c, h)
-					pp.TopPrintf(pp.EmojiBye, "Done now. Bye!")
+					ppfmt.Noticef(pp.EmojiSignal, "Caught signal: %v. Deleting all managed records . . .", sig)
+					clearIPs(ctx, ppfmt, c, h)
+					ppfmt.Noticef(pp.EmojiBye, "Done now. Bye!")
 				} else {
-					pp.TopPrintf(pp.EmojiSignal, "Caught signal: %v.", sig)
-					pp.TopPrintf(pp.EmojiBye, "Bye!")
+					ppfmt.Noticef(pp.EmojiSignal, "Caught signal: %v", sig)
+					ppfmt.Noticef(pp.EmojiBye, "Bye!")
 				}
 
 				break mainLoop
 
 			default:
-				pp.TopPrintf(pp.EmojiSignal, "Caught and ignored unexpected signal: %v.", sig)
+				ppfmt.Noticef(pp.EmojiSignal, "Caught and ignored unexpected signal: %v", sig)
 				continue mainLoop
 			}
 		}
diff --git a/cmd/ip.go b/cmd/ip.go
index 587ea1a..aad599d 100644
--- a/cmd/ip.go
+++ b/cmd/ip.go
@@ -11,12 +11,12 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/updator"
 )
 
-func setIP(ctx context.Context, indent pp.Indent, c *config.Config, h api.Handle, ipNet ipnet.Type, ip net.IP) {
+func setIP(ctx context.Context, ppfmt pp.PP, c *config.Config, h api.Handle, ipNet ipnet.Type, ip net.IP) {
 	for _, target := range c.Domains[ipNet] {
 		ctx, cancel := context.WithTimeout(ctx, c.UpdateTimeout)
 		defer cancel()
 
-		_ = updator.Do(ctx, indent, c.Quiet,
+		_ = updator.Do(ctx, ppfmt,
 			&updator.Args{
 				Handle:    h,
 				Domain:    target,
@@ -28,39 +28,32 @@ func setIP(ctx context.Context, indent pp.Indent, c *config.Config, h api.Handle
 	}
 }
 
-func updateIP(ctx context.Context, indent pp.Indent, c *config.Config, h api.Handle, ipNet ipnet.Type) {
+func updateIP(ctx context.Context, ppfmt pp.PP, c *config.Config, h api.Handle, ipNet ipnet.Type) {
 	ctx, cancel := context.WithTimeout(ctx, c.DetectionTimeout)
 	defer cancel()
 
-	ip := c.Policy[ipNet].GetIP(ctx, indent, ipNet)
+	ip := c.Policy[ipNet].GetIP(ctx, ppfmt, ipNet)
 	if ip == nil {
-		pp.TopPrintf(pp.EmojiError, "Failed to detect the %s address.", ipNet)
+		ppfmt.Errorf(pp.EmojiError, "Failed to detect the %s address", ipNet.Describe())
 		return
 	}
 
-	if !c.Quiet {
-		pp.TopPrintf(pp.EmojiInternet, "Detected the %s address: %v", ipNet, ip)
-	}
-
-	setIP(ctx, indent, c, h, ipNet, ip)
+	ppfmt.Infof(pp.EmojiInternet, "Detected the %s address: %v", ipNet.Describe(), ip)
+	setIP(ctx, ppfmt, c, h, ipNet, ip)
 }
 
-func updateIPs(ctx context.Context, indent pp.Indent, c *config.Config, h api.Handle) {
-	if c.Policy[ipnet.IP4].IsManaged() {
-		updateIP(ctx, indent, c, h, ipnet.IP4)
-	}
-
-	if c.Policy[ipnet.IP6].IsManaged() {
-		updateIP(ctx, indent, c, h, ipnet.IP6)
+func updateIPs(ctx context.Context, ppfmt pp.PP, c *config.Config, h api.Handle) {
+	for _, ipNet := range []ipnet.Type{ipnet.IP4, ipnet.IP6} {
+		if c.Policy[ipNet].IsManaged() {
+			updateIP(ctx, ppfmt, c, h, ipNet)
+		}
 	}
 }
 
-func clearIPs(ctx context.Context, indent pp.Indent, c *config.Config, h api.Handle) {
-	if c.Policy[ipnet.IP4].IsManaged() {
-		setIP(ctx, indent, c, h, ipnet.IP4, nil)
-	}
-
-	if c.Policy[ipnet.IP6].IsManaged() {
-		setIP(ctx, indent, c, h, ipnet.IP6, nil)
+func clearIPs(ctx context.Context, ppfmt pp.PP, c *config.Config, h api.Handle) {
+	for _, ipNet := range []ipnet.Type{ipnet.IP4, ipnet.IP6} {
+		if c.Policy[ipNet].IsManaged() {
+			setIP(ctx, ppfmt, c, h, ipNet, nil)
+		}
 	}
 }
diff --git a/cmd/priviledges.go b/cmd/priviledges.go
index b527c3f..cb565c2 100644
--- a/cmd/priviledges.go
+++ b/cmd/priviledges.go
@@ -7,7 +7,6 @@ import (
 
 	"github.com/favonia/cloudflare-ddns/internal/config"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
-	"github.com/favonia/cloudflare-ddns/internal/quiet"
 )
 
 // tryRaiseCap will attempt raise the capabilities.
@@ -27,7 +26,7 @@ func tryRaiseCap(val cap.Value) {
 	}
 }
 
-func dropSuperuserGroup(indent pp.Indent) {
+func dropSuperuserGroup(ppfmt pp.PP) {
 	defaultGID := syscall.Getegid()
 	if defaultGID == 0 {
 		defaultGID = syscall.Getgid() // real group ID
@@ -37,10 +36,10 @@ func dropSuperuserGroup(indent pp.Indent) {
 	}
 
 	gid := defaultGID
-	if !config.ReadNonnegInt(quiet.QUIET, indent, "PGID", &gid) {
+	if !config.ReadNonnegInt(ppfmt, "PGID", &gid) {
 		gid = defaultGID
 	} else if gid == 0 {
-		pp.Printf(indent, pp.EmojiUserError, "PGID cannot be 0. Using %d instead.", defaultGID)
+		ppfmt.Errorf(pp.EmojiUserError, "PGID cannot be 0. Using %d instead", defaultGID)
 		gid = defaultGID
 	}
 
@@ -48,15 +47,15 @@ func dropSuperuserGroup(indent pp.Indent) {
 	tryRaiseCap(cap.SETGID)
 
 	if err := syscall.Setgroups([]int{}); err != nil {
-		pp.Printf(indent, pp.EmojiBullet, "Failed to erase supplementary GIDs (which might be fine): %v", err)
+		ppfmt.Infof(pp.EmojiBullet, "Failed to erase supplementary GIDs (which might be fine): %v", err)
 	}
 
 	if err := syscall.Setresgid(gid, gid, gid); err != nil {
-		pp.Printf(indent, pp.EmojiUserError, "Failed to set GID to %d: %v", gid, err)
+		ppfmt.Errorf(pp.EmojiUserError, "Failed to set GID to %d: %v", gid, err)
 	}
 }
 
-func dropSuperuser(indent pp.Indent) {
+func dropSuperuser(ppfmt pp.PP) {
 	defaultUID := syscall.Geteuid()
 	if defaultUID == 0 {
 		defaultUID = syscall.Getuid()
@@ -66,10 +65,10 @@ func dropSuperuser(indent pp.Indent) {
 	}
 
 	uid := defaultUID
-	if !config.ReadNonnegInt(quiet.QUIET, indent, "PUID", &uid) {
+	if !config.ReadNonnegInt(ppfmt, "PUID", &uid) {
 		uid = defaultUID
 	} else if uid == 0 {
-		pp.Printf(indent, pp.EmojiUserError, "PUID cannot be 0. Using %d instead.", defaultUID)
+		ppfmt.Errorf(pp.EmojiUserError, "PUID cannot be 0. Using %d instead", defaultUID)
 		uid = defaultUID
 	}
 
@@ -77,58 +76,62 @@ func dropSuperuser(indent pp.Indent) {
 	tryRaiseCap(cap.SETUID)
 
 	if err := syscall.Setresuid(uid, uid, uid); err != nil {
-		pp.Printf(indent, pp.EmojiUserError, "Failed to set UID to %d: %v", uid, err)
+		ppfmt.Errorf(pp.EmojiUserError, "Failed to set UID to %d: %v", uid, err)
 	}
 }
 
-func dropCapabilities(indent pp.Indent) {
+func dropCapabilities(ppfmt pp.PP) {
 	if err := cap.NewSet().SetProc(); err != nil {
-		pp.Printf(indent, pp.EmojiImpossible, "Failed to drop all capabilities: %v", err)
+		ppfmt.Errorf(pp.EmojiImpossible, "Failed to drop all capabilities: %v", err)
 	}
 }
 
 // dropPriviledges drops all privileges as much as possible.
-func dropPriviledges(indent pp.Indent) {
-	pp.TopPrint(pp.EmojiPriviledges, "Dropping privileges . . .")
+func dropPriviledges(ppfmt pp.PP) {
+	if ppfmt.IsEnabledFor(pp.Info) {
+		ppfmt.Infof(pp.EmojiPriviledges, "Dropping privileges . . .")
+		ppfmt = ppfmt.IncIndent()
+	}
 
 	// group ID
-	dropSuperuserGroup(indent + 1)
+	dropSuperuserGroup(ppfmt)
 
 	// user ID
-	dropSuperuser(indent + 1)
+	dropSuperuser(ppfmt)
 
 	// all remaining capabilities
-	dropCapabilities(indent + 1)
+	dropCapabilities(ppfmt)
 }
 
-func printCapabilities(indent pp.Indent) {
+func printCapabilities(ppfmt pp.PP) {
 	now, err := cap.GetPID(0)
 	if err != nil {
-		pp.Printf(indent, pp.EmojiImpossible, "Failed to get the current capabilities: %v", err)
+		ppfmt.Errorf(pp.EmojiImpossible, "Failed to get the current capabilities: %v", err)
 	} else {
 		diff, err := now.Compare(cap.NewSet())
 		if err != nil {
-			pp.Printf(indent, pp.EmojiImpossible, "Failed to compare capabilities: %v", err)
+			ppfmt.Errorf(pp.EmojiImpossible, "Failed to compare capabilities: %v", err)
 		} else if diff != 0 {
-			pp.Printf(indent, pp.EmojiError, "The program still retains some additional capabilities: %v", now)
+			ppfmt.Errorf(pp.EmojiError, "The program still retains some additional capabilities: %v", now)
 		}
 	}
 }
 
-func printPriviledges(indent pp.Indent) {
-	pp.TopPrint(pp.EmojiPriviledges, "Priviledges after dropping:")
+func printPriviledges(ppfmt pp.PP) {
+	ppfmt.Noticef(pp.EmojiPriviledges, "Priviledges after dropping:")
+	inner := ppfmt.IncIndent()
 
-	pp.Printf(indent+1, pp.EmojiBullet, "Effective UID:      %d", syscall.Geteuid())
-	pp.Printf(indent+1, pp.EmojiBullet, "Effective GID:      %d", syscall.Getegid())
+	inner.Noticef(pp.EmojiBullet, "Effective UID:      %d", syscall.Geteuid())
+	inner.Noticef(pp.EmojiBullet, "Effective GID:      %d", syscall.Getegid())
 
 	switch groups, err := syscall.Getgroups(); {
 	case err != nil:
-		pp.Printf(indent+1, pp.EmojiImpossible, "Supplementary GIDs: (failed to get them)")
+		inner.Errorf(pp.EmojiImpossible, "Supplementary GIDs: (failed to get them)")
 	case len(groups) > 0:
-		pp.Printf(indent+1, pp.EmojiBullet, "Supplementary GIDs: %d", groups)
+		inner.Noticef(pp.EmojiBullet, "Supplementary GIDs: %d", groups)
 	default:
-		pp.Print(indent+1, pp.EmojiBullet, "Supplementary GIDs: (empty)")
+		inner.Noticef(pp.EmojiBullet, "Supplementary GIDs: (empty)")
 	}
 
-	printCapabilities(indent + 1)
+	printCapabilities(inner)
 }
diff --git a/go.mod b/go.mod
index 496584f..9f47bd8 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.16
 
 require (
 	github.com/cloudflare/cloudflare-go v0.20.0
+	github.com/golang/mock v1.6.0
 	github.com/patrickmn/go-cache v2.1.0+incompatible
 	github.com/robfig/cron/v3 v3.0.1
 	github.com/stretchr/testify v1.7.0
diff --git a/go.sum b/go.sum
index 8d865a2..066eb39 100644
--- a/go.sum
+++ b/go.sum
@@ -5,6 +5,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:ma
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
+github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
 github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -26,17 +28,37 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
 github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
+github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
 golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985 h1:4CSI6oo7cOjJKajidEljs9h+uP0rRZBPPPhcCbj5mw8=
 golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE=
 golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/internal/api/auth.go b/internal/api/auth.go
index a885778..b85632d 100644
--- a/internal/api/auth.go
+++ b/internal/api/auth.go
@@ -8,5 +8,5 @@ import (
 )
 
 type Auth interface {
-	New(context.Context, pp.Indent, time.Duration) (Handle, bool)
+	New(context.Context, pp.PP, time.Duration) (Handle, bool)
 }
diff --git a/internal/api/base.go b/internal/api/base.go
index ae228dd..1fcf6cc 100644
--- a/internal/api/base.go
+++ b/internal/api/base.go
@@ -8,14 +8,13 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
+//go:generate mockgen -destination=../mocks/mock_api.go -package=mocks . Handle
+
 type Handle interface {
-	ListRecords(ctx context.Context, indent pp.Indent,
-		domain FQDN, ipNet ipnet.Type) (map[string]net.IP, bool)
-	DeleteRecord(ctx context.Context, indent pp.Indent,
-		domain FQDN, ipNet ipnet.Type, id string) bool
-	UpdateRecord(ctx context.Context, indent pp.Indent,
-		domain FQDN, ipNet ipnet.Type, id string, ip net.IP) bool
-	CreateRecord(ctx context.Context, indent pp.Indent,
-		domain FQDN, ipNet ipnet.Type, ip net.IP, ttl int, proxied bool) (string, bool)
+	ListRecords(ctx context.Context, ppfmt pp.PP, domain FQDN, ipNet ipnet.Type) (map[string]net.IP, bool)
+	DeleteRecord(ctx context.Context, ppfmt pp.PP, domain FQDN, ipNet ipnet.Type, id string) bool
+	UpdateRecord(ctx context.Context, ppfmt pp.PP, domain FQDN, ipNet ipnet.Type, id string, ip net.IP) bool
+	CreateRecord(ctx context.Context, ppfmt pp.PP, domain FQDN, ipNet ipnet.Type,
+		ip net.IP, ttl TTL, proxied bool) (string, bool)
 	FlushCache()
 }
diff --git a/internal/api/cloudflare.go b/internal/api/cloudflare.go
index 5696c81..cf82ad7 100644
--- a/internal/api/cloudflare.go
+++ b/internal/api/cloudflare.go
@@ -33,10 +33,10 @@ type CloudflareAuth struct {
 	BaseURL   string
 }
 
-func (t *CloudflareAuth) New(ctx context.Context, indent pp.Indent, cacheExpiration time.Duration) (Handle, bool) {
+func (t *CloudflareAuth) New(ctx context.Context, ppfmt pp.PP, cacheExpiration time.Duration) (Handle, bool) {
 	handle, err := cloudflare.NewWithAPIToken(t.Token, cloudflare.UsingAccount(t.AccountID))
 	if err != nil {
-		pp.Printf(indent, pp.EmojiUserError, "Failed to prepare the Cloudflare authentication: %v", err)
+		ppfmt.Errorf(pp.EmojiUserError, "Failed to prepare the Cloudflare authentication: %v", err)
 		return nil, false
 	}
 
@@ -47,8 +47,8 @@ func (t *CloudflareAuth) New(ctx context.Context, indent pp.Indent, cacheExpirat
 
 	// this is not needed, but is helpful for diagnosing the problem
 	if _, err := handle.VerifyAPIToken(ctx); err != nil {
-		pp.Printf(indent, pp.EmojiUserError, "The Cloudflare API token is not valid: %v", err)
-		pp.Printf(indent, pp.EmojiUserError, "Please double-check CF_API_TOKEN or CF_API_TOKEN_FILE.")
+		ppfmt.Errorf(pp.EmojiUserError, "The Cloudflare API token is not valid: %v", err)
+		ppfmt.Errorf(pp.EmojiUserError, "Please double-check CF_API_TOKEN or CF_API_TOKEN_FILE")
 		return nil, false
 	}
 
@@ -75,7 +75,7 @@ func (h *CloudflareHandle) FlushCache() {
 }
 
 // ActiveZones lists all active zones of the given name.
-func (h *CloudflareHandle) ActiveZones(ctx context.Context, indent pp.Indent, name string) ([]string, bool) {
+func (h *CloudflareHandle) ActiveZones(ctx context.Context, ppfmt pp.PP, name string) ([]string, bool) {
 	// WithZoneFilters does not work with the empty zone name,
 	// and the owner of the DNS root zone will not be managed by Cloudflare anyways!
 	if name == "" {
@@ -88,7 +88,7 @@ func (h *CloudflareHandle) ActiveZones(ctx context.Context, indent pp.Indent, na
 
 	res, err := h.cf.ListZonesContext(ctx, cloudflare.WithZoneFilters(name, h.cf.AccountID, "active"))
 	if err != nil {
-		pp.Printf(indent, pp.EmojiError, "Failed to check the existence of a zone named %q: %v", name, err)
+		ppfmt.Warningf(pp.EmojiError, "Failed to check the existence of a zone named %q: %v", name, err)
 		return nil, false
 	}
 
@@ -102,7 +102,7 @@ func (h *CloudflareHandle) ActiveZones(ctx context.Context, indent pp.Indent, na
 	return ids, true
 }
 
-func (h *CloudflareHandle) ZoneOfDomain(ctx context.Context, indent pp.Indent, domain FQDN) (string, bool) {
+func (h *CloudflareHandle) ZoneOfDomain(ctx context.Context, ppfmt pp.PP, domain FQDN) (string, bool) {
 	if id, found := h.cache.zoneOfDomain.Get(domain.ToASCII()); found {
 		return id.(string), true
 	}
@@ -110,7 +110,7 @@ func (h *CloudflareHandle) ZoneOfDomain(ctx context.Context, indent pp.Indent, d
 zoneSearch:
 	for s := NewFQDNSplitter(domain); s.IsValid(); s.Next() {
 		zoneName := s.Suffix()
-		zones, ok := h.ActiveZones(ctx, indent, zoneName)
+		zones, ok := h.ActiveZones(ctx, ppfmt, zoneName)
 		if !ok {
 			return "", false
 		}
@@ -122,23 +122,23 @@ zoneSearch:
 			h.cache.zoneOfDomain.SetDefault(domain.ToASCII(), zones[0])
 			return zones[0], true
 		default: // len(zones) > 1
-			pp.Printf(indent, pp.EmojiImpossible,
-				"Found multiple active zones named %q. Specifying CF_ACCOUNT_ID might help.", zoneName)
+			ppfmt.Warningf(pp.EmojiImpossible,
+				"Found multiple active zones named %q. Specifying CF_ACCOUNT_ID might help", zoneName)
 			return "", false
 		}
 	}
 
-	pp.Printf(indent, pp.EmojiError, "Failed to find the zone of %q.", domain.Describe())
+	ppfmt.Warningf(pp.EmojiError, "Failed to find the zone of %q", domain.Describe())
 	return "", false
 }
 
-func (h *CloudflareHandle) ListRecords(ctx context.Context, indent pp.Indent,
+func (h *CloudflareHandle) ListRecords(ctx context.Context, ppfmt pp.PP,
 	domain FQDN, ipNet ipnet.Type) (map[string]net.IP, bool) {
 	if rmap, found := h.cache.listRecords[ipNet].Get(domain.ToASCII()); found {
 		return rmap.(map[string]net.IP), true
 	}
 
-	zone, ok := h.ZoneOfDomain(ctx, indent, domain)
+	zone, ok := h.ZoneOfDomain(ctx, ppfmt, domain)
 	if !ok {
 		return nil, false
 	}
@@ -149,7 +149,7 @@ func (h *CloudflareHandle) ListRecords(ctx context.Context, indent pp.Indent,
 		Type: ipNet.RecordType(),
 	})
 	if err != nil {
-		pp.Printf(indent, pp.EmojiError, "Failed to retrieve records of %q: %v", domain.Describe(), err)
+		ppfmt.Warningf(pp.EmojiError, "Failed to retrieve records of %q: %v", domain.Describe(), err)
 		return nil, false
 	}
 
@@ -163,15 +163,15 @@ func (h *CloudflareHandle) ListRecords(ctx context.Context, indent pp.Indent,
 	return rmap, true
 }
 
-func (h *CloudflareHandle) DeleteRecord(ctx context.Context, indent pp.Indent,
+func (h *CloudflareHandle) DeleteRecord(ctx context.Context, ppfmt pp.PP,
 	domain FQDN, ipNet ipnet.Type, id string) bool {
-	zone, ok := h.ZoneOfDomain(ctx, indent, domain)
+	zone, ok := h.ZoneOfDomain(ctx, ppfmt, domain)
 	if !ok {
 		return false
 	}
 
 	if err := h.cf.DeleteDNSRecord(ctx, zone, id); err != nil {
-		pp.Printf(indent, pp.EmojiError, "Failed to delete a stale %s record of %q (ID: %s): %v",
+		ppfmt.Warningf(pp.EmojiError, "Failed to delete a stale %s record of %q (ID: %s): %v",
 			ipNet.RecordType(), domain.Describe(), id, err)
 
 		h.cache.listRecords[ipNet].Delete(domain.ToASCII())
@@ -186,9 +186,9 @@ func (h *CloudflareHandle) DeleteRecord(ctx context.Context, indent pp.Indent,
 	return true
 }
 
-func (h *CloudflareHandle) UpdateRecord(ctx context.Context, indent pp.Indent,
+func (h *CloudflareHandle) UpdateRecord(ctx context.Context, ppfmt pp.PP,
 	domain FQDN, ipNet ipnet.Type, id string, ip net.IP) bool {
-	zone, ok := h.ZoneOfDomain(ctx, indent, domain)
+	zone, ok := h.ZoneOfDomain(ctx, ppfmt, domain)
 	if !ok {
 		return false
 	}
@@ -201,7 +201,7 @@ func (h *CloudflareHandle) UpdateRecord(ctx context.Context, indent pp.Indent,
 	}
 
 	if err := h.cf.UpdateDNSRecord(ctx, zone, id, payload); err != nil {
-		pp.Printf(indent, pp.EmojiError, "Failed to update a stale %s record of %q (ID: %s): %v",
+		ppfmt.Warningf(pp.EmojiError, "Failed to update a stale %s record of %q (ID: %s): %v",
 			ipNet.RecordType(), domain.Describe(), id, err)
 
 		h.cache.listRecords[ipNet].Delete(domain.ToASCII())
@@ -216,9 +216,9 @@ func (h *CloudflareHandle) UpdateRecord(ctx context.Context, indent pp.Indent,
 	return true
 }
 
-func (h *CloudflareHandle) CreateRecord(ctx context.Context, indent pp.Indent,
-	domain FQDN, ipNet ipnet.Type, ip net.IP, ttl int, proxied bool) (string, bool) {
-	zone, ok := h.ZoneOfDomain(ctx, indent, domain)
+func (h *CloudflareHandle) CreateRecord(ctx context.Context, ppfmt pp.PP,
+	domain FQDN, ipNet ipnet.Type, ip net.IP, ttl TTL, proxied bool) (string, bool) {
+	zone, ok := h.ZoneOfDomain(ctx, ppfmt, domain)
 	if !ok {
 		return "", false
 	}
@@ -228,13 +228,13 @@ func (h *CloudflareHandle) CreateRecord(ctx context.Context, indent pp.Indent,
 		Name:    domain.ToASCII(),
 		Type:    ipNet.RecordType(),
 		Content: ip.String(),
-		TTL:     ttl,
+		TTL:     ttl.Int(),
 		Proxied: &proxied,
 	}
 
 	res, err := h.cf.CreateDNSRecord(ctx, zone, payload)
 	if err != nil {
-		pp.Printf(indent, pp.EmojiError, "Failed to add a new %s record of %q: %v",
+		ppfmt.Warningf(pp.EmojiError, "Failed to add a new %s record of %q: %v",
 			ipNet.RecordType(), domain.Describe(), err)
 
 		h.cache.listRecords[ipNet].Delete(domain.ToASCII())
diff --git a/internal/api/fqdn.go b/internal/api/fqdn.go
index e575207..7a68d19 100644
--- a/internal/api/fqdn.go
+++ b/internal/api/fqdn.go
@@ -7,7 +7,7 @@ import (
 	"golang.org/x/net/idna"
 )
 
-//nolint: gochecknoglobals
+//nolint:gochecknoglobals
 // profile does C2 in UTS#46 with all checks on + removing leading dots.
 // This is the main conversion profile in use.
 var profile = idna.New(
diff --git a/internal/config/config.go b/internal/config/config.go
index da5b345..a2759e1 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -9,11 +9,9 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/file"
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
-	"github.com/favonia/cloudflare-ddns/internal/quiet"
 )
 
 type Config struct {
-	Quiet            quiet.Quiet
 	Auth             api.Auth
 	Policy           map[ipnet.Type]detector.Policy
 	Domains          map[ipnet.Type][]api.FQDN
@@ -30,8 +28,7 @@ type Config struct {
 // Default gives default values.
 func Default() *Config {
 	return &Config{
-		Quiet: quiet.VERBOSE,
-		Auth:  nil,
+		Auth: nil,
 		Policy: map[ipnet.Type]detector.Policy{
 			ipnet.IP4: detector.NewCloudflare(),
 			ipnet.IP6: detector.NewCloudflare(),
@@ -51,7 +48,7 @@ func Default() *Config {
 	}
 }
 
-func readAuthToken(_ quiet.Quiet, indent pp.Indent) (string, bool) {
+func readAuthToken(ppfmt pp.PP) (string, bool) {
 	var (
 		token     = Getenv("CF_API_TOKEN")
 		tokenFile = Getenv("CF_API_TOKEN_FILE")
@@ -59,36 +56,36 @@ func readAuthToken(_ quiet.Quiet, indent pp.Indent) (string, bool) {
 
 	// foolproof checks
 	if token == "YOUR-CLOUDFLARE-API-TOKEN" {
-		pp.Printf(indent, pp.EmojiUserError, "You need to provide a real API token as CF_API_TOKEN.")
+		ppfmt.Errorf(pp.EmojiUserError, "You need to provide a real API token as CF_API_TOKEN")
 		return "", false
 	}
 
 	switch {
 	case token != "" && tokenFile != "":
-		pp.Printf(indent, pp.EmojiUserError, "Cannot have both CF_API_TOKEN and CF_API_TOKEN_FILE set.")
+		ppfmt.Errorf(pp.EmojiUserError, "Cannot have both CF_API_TOKEN and CF_API_TOKEN_FILE set")
 		return "", false
 	case token != "":
 		return token, true
 	case tokenFile != "":
-		token, ok := file.ReadString(indent, tokenFile)
+		token, ok := file.ReadString(ppfmt, tokenFile)
 		if !ok {
 			return "", false
 		}
 
 		if token == "" {
-			pp.Printf(indent, pp.EmojiUserError, "The token in the file specified by CF_API_TOKEN_FILE is empty.")
+			ppfmt.Errorf(pp.EmojiUserError, "The token in the file specified by CF_API_TOKEN_FILE is empty")
 			return "", false
 		}
 
 		return token, true
 	default:
-		pp.Printf(indent, pp.EmojiUserError, "Needs either CF_API_TOKEN or CF_API_TOKEN_FILE.")
+		ppfmt.Errorf(pp.EmojiUserError, "Needs either CF_API_TOKEN or CF_API_TOKEN_FILE")
 		return "", false
 	}
 }
 
-func ReadAuth(quiet quiet.Quiet, indent pp.Indent, field *api.Auth) bool {
-	token, ok := readAuthToken(quiet, indent)
+func ReadAuth(ppfmt pp.PP, field *api.Auth) bool {
+	token, ok := readAuthToken(ppfmt)
 	if !ok {
 		return false
 	}
@@ -124,12 +121,12 @@ func deduplicate(list *[]api.FQDN) {
 	*list = (*list)[:j+1]
 }
 
-func ReadDomainMap(quiet quiet.Quiet, indent pp.Indent, field map[ipnet.Type][]api.FQDN) bool {
+func ReadDomainMap(ppfmt pp.PP, field map[ipnet.Type][]api.FQDN) bool {
 	var domains, ip4Domains, ip6Domains []api.FQDN
 
-	if !ReadDomains(quiet, indent, "DOMAINS", &domains) ||
-		!ReadDomains(quiet, indent, "IP4_DOMAINS", &ip4Domains) ||
-		!ReadDomains(quiet, indent, "IP6_DOMAINS", &ip6Domains) {
+	if !ReadDomains(ppfmt, "DOMAINS", &domains) ||
+		!ReadDomains(ppfmt, "IP4_DOMAINS", &ip4Domains) ||
+		!ReadDomains(ppfmt, "IP6_DOMAINS", &ip6Domains) {
 		return false
 	}
 
@@ -145,12 +142,12 @@ func ReadDomainMap(quiet quiet.Quiet, indent pp.Indent, field map[ipnet.Type][]a
 	return true
 }
 
-func ReadPolicyMap(quiet quiet.Quiet, indent pp.Indent, field map[ipnet.Type]detector.Policy) bool {
+func ReadPolicyMap(ppfmt pp.PP, field map[ipnet.Type]detector.Policy) bool {
 	ip4Policy := field[ipnet.IP4]
 	ip6Policy := field[ipnet.IP6]
 
-	if !ReadPolicy(quiet, indent, "IP4_POLICY", &ip4Policy) ||
-		!ReadPolicy(quiet, indent, "IP6_POLICY", &ip6Policy) {
+	if !ReadPolicy(ppfmt, "IP4_POLICY", &ip4Policy) ||
+		!ReadPolicy(ppfmt, "IP6_POLICY", &ip6Policy) {
 		return false
 	}
 
@@ -159,113 +156,101 @@ func ReadPolicyMap(quiet quiet.Quiet, indent pp.Indent, field map[ipnet.Type]det
 	return true
 }
 
-func PrintConfig(indent pp.Indent, c *Config) {
-	pp.Printf(indent, pp.EmojiConfig, "Policies:")
-	pp.Printf(indent+1, pp.EmojiBullet, "IPv4 policy:      %v", c.Policy[ipnet.IP4])
+func PrintConfig(ppfmt pp.PP, c *Config) {
+	ppfmt.Infof(pp.EmojiEnvVars, "Current settings:")
+	ppfmt = ppfmt.IncIndent()
+
+	inner := ppfmt.IncIndent()
+
+	ppfmt.Infof(pp.EmojiConfig, "Policies:")
+	inner.Infof(pp.EmojiBullet, "IPv4 policy:      %v", c.Policy[ipnet.IP4])
 	if c.Policy[ipnet.IP4].IsManaged() {
-		pp.Printf(indent+1, pp.EmojiBullet, "IPv4 domains:     %v", c.Domains[ipnet.IP4])
+		inner.Infof(pp.EmojiBullet, "IPv4 domains:     %v", c.Domains[ipnet.IP4])
 	}
-	pp.Printf(indent+1, pp.EmojiBullet, "IPv6 policy:      %v", c.Policy[ipnet.IP6])
+	inner.Infof(pp.EmojiBullet, "IPv6 policy:      %v", c.Policy[ipnet.IP6])
 	if c.Policy[ipnet.IP6].IsManaged() {
-		pp.Printf(indent+1, pp.EmojiBullet, "IPv6 domains:     %v", c.Domains[ipnet.IP6])
+		inner.Infof(pp.EmojiBullet, "IPv6 domains:     %v", c.Domains[ipnet.IP6])
 	}
 
-	pp.Printf(indent, pp.EmojiConfig, "Scheduling:")
-	pp.Printf(indent+1, pp.EmojiBullet, "Timezone:         %s", cron.DescribeLocation(time.Local))
-	pp.Printf(indent+1, pp.EmojiBullet, "Update frequency: %v", c.UpdateCron)
-	pp.Printf(indent+1, pp.EmojiBullet, "Update on start?  %t", c.UpdateOnStart)
-	pp.Printf(indent+1, pp.EmojiBullet, "Delete on stop?   %t", c.DeleteOnStop)
-	pp.Printf(indent+1, pp.EmojiBullet, "Cache expiration: %v", c.CacheExpiration)
+	ppfmt.Infof(pp.EmojiConfig, "Scheduling:")
+	inner.Infof(pp.EmojiBullet, "Timezone:         %s", cron.DescribeLocation(time.Local))
+	inner.Infof(pp.EmojiBullet, "Update frequency: %v", c.UpdateCron)
+	inner.Infof(pp.EmojiBullet, "Update on start?  %t", c.UpdateOnStart)
+	inner.Infof(pp.EmojiBullet, "Delete on stop?   %t", c.DeleteOnStop)
+	inner.Infof(pp.EmojiBullet, "Cache expiration: %v", c.CacheExpiration)
 
-	pp.Printf(indent, pp.EmojiConfig, "New DNS records:")
-	pp.Printf(indent+1, pp.EmojiBullet, "TTL:              %s", c.TTL.Describe())
-	pp.Printf(indent+1, pp.EmojiBullet, "Proxied:          %t", c.Proxied)
+	ppfmt.Infof(pp.EmojiConfig, "New DNS records:")
+	inner.Infof(pp.EmojiBullet, "TTL:              %s", c.TTL.Describe())
+	inner.Infof(pp.EmojiBullet, "Proxied:          %t", c.Proxied)
 
-	pp.Printf(indent, pp.EmojiConfig, "Timeouts")
-	pp.Printf(indent+1, pp.EmojiBullet, "IP detection:     %v", c.DetectionTimeout)
+	ppfmt.Infof(pp.EmojiConfig, "Timeouts")
+	inner.Infof(pp.EmojiBullet, "IP detection:     %v", c.DetectionTimeout)
 }
 
-func (c *Config) ReadEnv(indent pp.Indent) bool { //nolint:cyclop
-	if !ReadQuiet(indent, "QUIET", &c.Quiet) {
-		return false
-	}
-
-	if c.Quiet {
-		pp.Printf(indent, pp.EmojiMute, "Quiet mode enabled.")
-	} else {
-		pp.Printf(indent, pp.EmojiEnvVars, "Reading settings . . .")
-		indent++
+func (c *Config) ReadEnv(ppfmt pp.PP) bool { //nolint:cyclop
+	if ppfmt.IsEnabledFor(pp.Info) {
+		ppfmt.Noticef(pp.EmojiEnvVars, "Reading settings . . .")
+		ppfmt = ppfmt.IncIndent()
 	}
 
-	if !ReadAuth(c.Quiet, indent, &c.Auth) ||
-		!ReadPolicyMap(c.Quiet, indent, c.Policy) ||
-		!ReadDomainMap(c.Quiet, indent, c.Domains) ||
-		!ReadCron(c.Quiet, indent, "UPDATE_CRON", &c.UpdateCron) ||
-		!ReadBool(c.Quiet, indent, "UPDATE_ON_START", &c.UpdateOnStart) ||
-		!ReadBool(c.Quiet, indent, "DELETE_ON_STOP", &c.DeleteOnStop) ||
-		!ReadNonnegDuration(c.Quiet, indent, "CACHE_EXPIRATION", &c.CacheExpiration) ||
-		!ReadNonnegInt(c.Quiet, indent, "TTL", (*int)(&c.TTL)) ||
-		!ReadBool(c.Quiet, indent, "PROXIED", &c.Proxied) ||
-		!ReadNonnegDuration(c.Quiet, indent, "DETECTION_TIMEOUT", &c.DetectionTimeout) {
+	if !ReadAuth(ppfmt, &c.Auth) ||
+		!ReadPolicyMap(ppfmt, c.Policy) ||
+		!ReadDomainMap(ppfmt, c.Domains) ||
+		!ReadCron(ppfmt, "UPDATE_CRON", &c.UpdateCron) ||
+		!ReadBool(ppfmt, "UPDATE_ON_START", &c.UpdateOnStart) ||
+		!ReadBool(ppfmt, "DELETE_ON_STOP", &c.DeleteOnStop) ||
+		!ReadNonnegDuration(ppfmt, "CACHE_EXPIRATION", &c.CacheExpiration) ||
+		!ReadNonnegInt(ppfmt, "TTL", (*int)(&c.TTL)) ||
+		!ReadBool(ppfmt, "PROXIED", &c.Proxied) ||
+		!ReadNonnegDuration(ppfmt, "DETECTION_TIMEOUT", &c.DetectionTimeout) {
 		return false
 	}
 
 	return true
 }
 
-func (c *Config) checkUselessDomains(indent pp.Indent) {
-	var (
-		domainSet    = map[ipnet.Type]map[string]bool{ipnet.IP4: {}, ipnet.IP6: {}}
-		unionSet     = map[string]bool{}
-		intersectSet = map[string]bool{}
-	)
-	// calculate domainSet[IP4], domainSet[IP6], and unionSet
-	for ipNet, domains := range c.Domains {
+func (c *Config) checkUselessDomains(ppfmt pp.PP) {
+	count := map[api.FQDN]int{}
+	for _, domains := range c.Domains {
 		for _, domain := range domains {
-			domainString := domain.ToASCII()
-			domainSet[ipNet][domainString] = true
-			unionSet[domainString] = true
+			count[domain]++
 		}
 	}
 
-	// calculate intersectSet
-	for domain := range unionSet {
-		intersectSet[domain] = domainSet[ipnet.IP4][domain] && domainSet[ipnet.IP6][domain]
-	}
-
-	for ipNet := range c.Domains {
+	for ipNet, domains := range c.Domains {
 		if !c.Policy[ipNet].IsManaged() {
-			for domain := range domainSet[ipNet] {
-				if !intersectSet[domain] {
-					pp.Printf(indent, pp.EmojiUserWarning,
-						"Domain %v is ignored because it is only for %v but %v is unmanaged.", domain, ipNet, ipNet)
+			for i := range domains {
+				if count[domains[i]] != len(c.Domains) {
+					ppfmt.Warningf(pp.EmojiUserWarning,
+						"Domain %q is ignored because it is only for %s but %s is unmanaged",
+						domains[i].Describe(), ipNet.Describe(), ipNet.Describe())
 				}
 			}
 		}
 	}
 }
 
-func (c *Config) Normalize(indent pp.Indent) bool {
+func (c *Config) Normalize(ppfmt pp.PP) bool {
 	if len(c.Domains[ipnet.IP4]) == 0 && len(c.Domains[ipnet.IP6]) == 0 {
-		pp.Printf(indent, pp.EmojiUserError, "No domains were specified.")
+		ppfmt.Errorf(pp.EmojiUserError, "No domains were specified")
 		return false
 	}
 
-	// change useless policies to
+	// change useless policies to unmanaged
 	for ipNet, domains := range c.Domains {
 		if len(domains) == 0 && c.Policy[ipNet].IsManaged() {
 			c.Policy[ipNet] = detector.NewUnmanaged()
-			pp.Printf(indent, pp.EmojiUserWarning, "IP%v_POLICY was changed to %q because no domains were set for %v.",
+			ppfmt.Warningf(pp.EmojiUserWarning, "IP%d_POLICY was changed to %q because no domains were set for %v",
 				ipNet.Int(), c.Policy[ipNet], ipNet)
 		}
 	}
 
 	if !c.Policy[ipnet.IP4].IsManaged() && !c.Policy[ipnet.IP6].IsManaged() {
-		pp.Printf(indent, pp.EmojiUserError, "Both IPv4 and IPv6 are unmanaged.")
+		ppfmt.Errorf(pp.EmojiUserError, "Both IPv4 and IPv6 are unmanaged")
 		return false
 	}
 
-	c.checkUselessDomains(indent)
+	c.checkUselessDomains(ppfmt)
 
 	return true
 }
diff --git a/internal/config/env.go b/internal/config/env.go
index f9f9f98..9c6e2a5 100644
--- a/internal/config/env.go
+++ b/internal/config/env.go
@@ -10,7 +10,6 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/cron"
 	"github.com/favonia/cloudflare-ddns/internal/detector"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
-	"github.com/favonia/cloudflare-ddns/internal/quiet"
 )
 
 // Getenv reads an environment variable and trim the space.
@@ -19,7 +18,7 @@ func Getenv(key string) string {
 }
 
 // ReadQuiet reads an environment variable as quiet/verbose.
-func ReadQuiet(indent pp.Indent, key string, field *quiet.Quiet) bool {
+func ReadQuiet(key string, ppfmt *pp.PP) bool {
 	val := Getenv(key)
 	if val == "" {
 		return true
@@ -27,41 +26,30 @@ func ReadQuiet(indent pp.Indent, key string, field *quiet.Quiet) bool {
 
 	b, err := strconv.ParseBool(val)
 	if err != nil {
-		pp.Printf(indent, pp.EmojiUserError, "Failed to parse %q: %v", val, err)
+		(*ppfmt).Errorf(pp.EmojiUserError, "Failed to parse %q: %v", val, err)
 		return false
 	}
 
-	*field = quiet.Quiet(b)
-	return true
-}
-
-// ReadString reads an environment variable as a string.
-func ReadString(quiet quiet.Quiet, indent pp.Indent, key string, field *string) bool {
-	val := Getenv(key)
-	if val == "" {
-		if !quiet {
-			pp.Printf(indent, pp.EmojiBullet, "Use default %s=%s", key, *field)
-		}
-		return true
+	if b {
+		*ppfmt = (*ppfmt).SetLevel(pp.Quiet)
+	} else {
+		*ppfmt = (*ppfmt).SetLevel(pp.Verbose)
 	}
 
-	*field = val
 	return true
 }
 
 // ReadBool reads an environment variable as a boolean value.
-func ReadBool(quiet quiet.Quiet, indent pp.Indent, key string, field *bool) bool {
+func ReadBool(ppfmt pp.PP, key string, field *bool) bool {
 	val := Getenv(key)
 	if val == "" {
-		if !quiet {
-			pp.Printf(indent, pp.EmojiBullet, "Use default %s=%t", key, *field)
-		}
+		ppfmt.Infof(pp.EmojiBullet, "Use default %s=%t", key, *field)
 		return true
 	}
 
 	b, err := strconv.ParseBool(val)
 	if err != nil {
-		pp.Printf(indent, pp.EmojiUserError, "Failed to parse %q: %v", val, err)
+		ppfmt.Errorf(pp.EmojiUserError, "Failed to parse %q: %v", val, err)
 		return false
 	}
 
@@ -70,22 +58,20 @@ func ReadBool(quiet quiet.Quiet, indent pp.Indent, key string, field *bool) bool
 }
 
 // ReadNonnegInt reads an environment variable as an integer.
-func ReadNonnegInt(quiet quiet.Quiet, indent pp.Indent, key string, field *int) bool {
+func ReadNonnegInt(ppfmt pp.PP, key string, field *int) bool {
 	val := Getenv(key)
 	if val == "" {
-		if !quiet {
-			pp.Printf(indent, pp.EmojiBullet, "Use default %s=%d", key, *field)
-		}
+		ppfmt.Infof(pp.EmojiBullet, "Use default %s=%d", key, *field)
 		return true
 	}
 
 	i, err := strconv.Atoi(val)
 	switch {
 	case err != nil:
-		pp.Printf(indent, pp.EmojiUserError, "Failed to parse %q: %v", val, err)
+		ppfmt.Errorf(pp.EmojiUserError, "Failed to parse %q: %v", val, err)
 		return false
 	case i < 0:
-		pp.Printf(indent, pp.EmojiUserError, "Failed to parse %q: %d is negative.", val, i)
+		ppfmt.Errorf(pp.EmojiUserError, "Failed to parse %q: %d is negative", val, i)
 		return false
 	}
 
@@ -95,7 +81,7 @@ func ReadNonnegInt(quiet quiet.Quiet, indent pp.Indent, key string, field *int)
 
 // ReadDomains reads an environment variable as a comma-separated list of domains.
 // Spaces are trimed.
-func ReadDomains(_ quiet.Quiet, indent pp.Indent, key string, field *[]api.FQDN) bool {
+func ReadDomains(ppfmt pp.PP, key string, field *[]api.FQDN) bool {
 	rawList := strings.Split(Getenv(key), ",")
 
 	*field = make([]api.FQDN, 0, len(rawList))
@@ -107,7 +93,7 @@ func ReadDomains(_ quiet.Quiet, indent pp.Indent, key string, field *[]api.FQDN)
 
 		item, err := api.NewFQDN(item)
 		if err != nil {
-			pp.Printf(indent, pp.EmojiUserError, "Domain %q was added but it is ill-formed: %v", item, err)
+			ppfmt.Warningf(pp.EmojiUserError, "Domain %q was added but it is ill-formed: %v", item.Describe(), err)
 		}
 
 		*field = append(*field, item)
@@ -117,12 +103,10 @@ func ReadDomains(_ quiet.Quiet, indent pp.Indent, key string, field *[]api.FQDN)
 }
 
 // ReadPolicy reads an environment variable and parses it as a policy.
-func ReadPolicy(quiet quiet.Quiet, indent pp.Indent, key string, field *detector.Policy) bool {
+func ReadPolicy(ppfmt pp.PP, key string, field *detector.Policy) bool {
 	switch val := Getenv(key); val {
 	case "":
-		if !quiet {
-			pp.Printf(indent, pp.EmojiBullet, "Use default %s=%s", key, *field)
-		}
+		ppfmt.Infof(pp.EmojiBullet, "Use default %s=%s", key, (*field).String())
 		return true
 	case "cloudflare":
 		*field = detector.NewCloudflare()
@@ -137,18 +121,16 @@ func ReadPolicy(quiet quiet.Quiet, indent pp.Indent, key string, field *detector
 		*field = detector.NewUnmanaged()
 		return true
 	default:
-		pp.Printf(indent, pp.EmojiUserError, "Failed to parse %q: not a valid policy.", val)
+		ppfmt.Errorf(pp.EmojiUserError, "Failed to parse %q: not a valid policy", val)
 		return false
 	}
 }
 
 // ReadNonnegDuration reads an environment variable and parses it as a time duration.
-func ReadNonnegDuration(quiet quiet.Quiet, indent pp.Indent, key string, field *time.Duration) bool {
+func ReadNonnegDuration(ppfmt pp.PP, key string, field *time.Duration) bool {
 	val := Getenv(key)
 	if val == "" {
-		if !quiet {
-			pp.Printf(indent, pp.EmojiBullet, "Use default %s=%s", key, field)
-		}
+		ppfmt.Infof(pp.EmojiBullet, "Use default %s=%v", key, *field)
 		return true
 	}
 
@@ -156,10 +138,10 @@ func ReadNonnegDuration(quiet quiet.Quiet, indent pp.Indent, key string, field *
 
 	switch {
 	case err != nil:
-		pp.Printf(indent, pp.EmojiUserError, "Failed to parse %q: %v", val, err)
+		ppfmt.Errorf(pp.EmojiUserError, "Failed to parse %q: %v", val, err)
 		return false
 	case t < 0:
-		pp.Printf(indent, pp.EmojiUserError, "Failed to parse %q: %v is negative.", val, t)
+		ppfmt.Errorf(pp.EmojiUserError, "Failed to parse %q: %v is negative", val, t)
 		return false
 	}
 
@@ -168,18 +150,16 @@ func ReadNonnegDuration(quiet quiet.Quiet, indent pp.Indent, key string, field *
 }
 
 // ReadCron reads an environment variable and parses it as a Cron expression.
-func ReadCron(quiet quiet.Quiet, indent pp.Indent, key string, field *cron.Schedule) bool {
+func ReadCron(ppfmt pp.PP, key string, field *cron.Schedule) bool {
 	val := Getenv(key)
 	if val == "" {
-		if !quiet {
-			pp.Printf(indent, pp.EmojiBullet, "Use default %s=%s", key, *field)
-		}
+		ppfmt.Infof(pp.EmojiBullet, "Use default %s=%s", key, (*field).String())
 		return true
 	}
 
 	c, err := cron.New(val)
 	if err != nil {
-		pp.Printf(indent, pp.EmojiUserError, "Failed to parse %q: %v", val, err)
+		ppfmt.Errorf(pp.EmojiUserError, "Failed to parse %q: %v", val, err)
 		return false
 	}
 
diff --git a/internal/detector/dns.go b/internal/detector/dns.go
index b6a807d..c40b303 100644
--- a/internal/detector/dns.go
+++ b/internal/detector/dns.go
@@ -27,7 +27,7 @@ func randUint16() uint16 {
 	return binary.BigEndian.Uint16(buf)
 }
 
-func newDNSQuery(indent pp.Indent, id uint16, name string, class dnsmessage.Class) ([]byte, bool) {
+func newDNSQuery(ppfmt pp.PP, id uint16, name string, class dnsmessage.Class) ([]byte, bool) {
 	msg, err := (&dnsmessage.Message{
 		Header: dnsmessage.Header{
 			ID:               id,
@@ -52,7 +52,7 @@ func newDNSQuery(indent pp.Indent, id uint16, name string, class dnsmessage.Clas
 		Additionals: []dnsmessage.Resource{},
 	}).Pack()
 	if err != nil {
-		pp.Printf(indent, pp.EmojiError, "Failed to prepare the DNS query: %v", err)
+		ppfmt.Warningf(pp.EmojiError, "Failed to prepare the DNS query: %v", err)
 
 		return nil, false
 	}
@@ -60,7 +60,7 @@ func newDNSQuery(indent pp.Indent, id uint16, name string, class dnsmessage.Clas
 	return msg, true
 }
 
-func parseDNSAnswers(indent pp.Indent, answers []dnsmessage.Resource,
+func parseDNSAnswers(ppfmt pp.PP, answers []dnsmessage.Resource,
 	name string, class dnsmessage.Class) net.IP {
 	var ipString string
 
@@ -76,7 +76,7 @@ func parseDNSAnswers(indent pp.Indent, answers []dnsmessage.Resource,
 			}
 
 			if ipString != "" {
-				pp.Printf(indent, pp.EmojiImpossible, "Unexpected multiple non-empty strings in TXT records.")
+				ppfmt.Warningf(pp.EmojiImpossible, "Invalid DNS response: more than one string in TXT records")
 				return nil
 			}
 
@@ -85,47 +85,47 @@ func parseDNSAnswers(indent pp.Indent, answers []dnsmessage.Resource,
 	}
 
 	if ipString == "" {
-		pp.Printf(indent, pp.EmojiImpossible, "No TXT records or TXT records have no non-empty strings.")
+		ppfmt.Warningf(pp.EmojiImpossible, "Invalid DNS response: no TXT records or all TXT records are empty")
 		return nil
 	}
 
 	return net.ParseIP(ipString)
 }
 
-func parseDNSResponse(indent pp.Indent, r []byte, id uint16, name string, class dnsmessage.Class) net.IP {
+func parseDNSResponse(ppfmt pp.PP, r []byte, id uint16, name string, class dnsmessage.Class) net.IP {
 	var msg dnsmessage.Message
 	if err := msg.Unpack(r); err != nil {
-		pp.Printf(indent, pp.EmojiImpossible, "Not a valid DNS response: %v", err)
+		ppfmt.Warningf(pp.EmojiImpossible, "Invalid DNS response: %v", err)
 		return nil
 	}
 
 	switch {
 	case msg.ID != id:
-		pp.Printf(indent, pp.EmojiImpossible, "Response ID %x differs from the query ID %x.", id, msg.ID)
+		ppfmt.Warningf(pp.EmojiImpossible, "Invalid DNS response: mismatched transaction ID")
 		return nil
 
 	case !msg.Response:
-		pp.Printf(indent, pp.EmojiImpossible, "The QR (query/response) bit was not set in the response.")
+		ppfmt.Warningf(pp.EmojiImpossible, "Invalid DNS response: QR was not set")
 		return nil
 
 	case msg.Truncated:
-		pp.Printf(indent, pp.EmojiImpossible, "The TC (truncation) bit was set. Something went wrong.")
+		ppfmt.Warningf(pp.EmojiImpossible, "Invalid DNS response: TC was set")
 		return nil
 
 	case msg.RCode != dnsmessage.RCodeSuccess:
-		pp.Printf(indent, pp.EmojiImpossible, "The response code is %v. The query failed.", msg.RCode)
+		ppfmt.Warningf(pp.EmojiImpossible, "Invalid DNS response: response code is %v", msg.RCode)
 		return nil
 	}
 
-	return parseDNSAnswers(indent, msg.Answers, name, class)
+	return parseDNSAnswers(ppfmt, msg.Answers, name, class)
 }
 
-func getIPFromDNS(ctx context.Context, indent pp.Indent,
+func getIPFromDNS(ctx context.Context, ppfmt pp.PP,
 	url string, name string, class dnsmessage.Class) net.IP {
 	// message ID for the DNS payloads
 	id := randUint16()
 
-	q, ok := newDNSQuery(indent, id, name, class)
+	q, ok := newDNSQuery(ppfmt, id, name, class)
 	if !ok {
 		return nil
 	}
@@ -136,12 +136,12 @@ func getIPFromDNS(ctx context.Context, indent pp.Indent,
 		contentType: "application/dns-message",
 		accept:      "application/dns-message",
 		reader:      bytes.NewReader(q),
-		extract: func(indent pp.Indent, body []byte) net.IP {
-			return parseDNSResponse(indent, body, id, name, class)
+		extract: func(ppfmt pp.PP, body []byte) net.IP {
+			return parseDNSResponse(ppfmt, body, id, name, class)
 		},
 	}
 
-	return c.getIP(ctx, indent)
+	return c.getIP(ctx, ppfmt)
 }
 
 type DNSOverHTTPS struct {
@@ -161,11 +161,12 @@ func (p *DNSOverHTTPS) String() string {
 	return p.PolicyName
 }
 
-func (p *DNSOverHTTPS) GetIP(ctx context.Context, indent pp.Indent, ipNet ipnet.Type) net.IP {
+func (p *DNSOverHTTPS) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) net.IP {
 	param, found := p.Param[ipNet]
 	if !found {
+		ppfmt.Warningf(pp.EmojiImpossible, "Unhandled IP network: %s", ipNet.Describe())
 		return nil
 	}
 
-	return ipNet.NormalizeIP(getIPFromDNS(ctx, indent, param.URL, param.Name, param.Class))
+	return NormalizeIP(ppfmt, ipNet, getIPFromDNS(ctx, ppfmt, param.URL, param.Name, param.Class))
 }
diff --git a/internal/detector/http.go b/internal/detector/http.go
index 3a85261..b0fea4e 100644
--- a/internal/detector/http.go
+++ b/internal/detector/http.go
@@ -16,13 +16,13 @@ type httpConn struct {
 	contentType string
 	accept      string
 	reader      io.Reader
-	extract     func(pp.Indent, []byte) net.IP
+	extract     func(pp.PP, []byte) net.IP
 }
 
-func (d *httpConn) getIP(ctx context.Context, indent pp.Indent) net.IP {
+func (d *httpConn) getIP(ctx context.Context, ppfmt pp.PP) net.IP {
 	req, err := http.NewRequestWithContext(ctx, d.method, d.url, d.reader)
 	if err != nil {
-		pp.Printf(indent, pp.EmojiImpossible, "Failed to prepare the request to %q: %v", d.url, err)
+		ppfmt.Warningf(pp.EmojiImpossible, "Failed to prepare HTTP(S) request to %q: %v", d.url, err)
 		return nil
 	}
 
@@ -36,31 +36,31 @@ func (d *httpConn) getIP(ctx context.Context, indent pp.Indent) net.IP {
 
 	resp, err := http.DefaultClient.Do(req)
 	if err != nil {
-		pp.Printf(indent, pp.EmojiError, "Failed to send the request to %q: %v", d.url, err)
+		ppfmt.Warningf(pp.EmojiError, "Failed to send HTTP(S) request to %q: %v", d.url, err)
 		return nil
 	}
 	defer resp.Body.Close()
 
 	body, err := io.ReadAll(resp.Body)
 	if err != nil {
-		pp.Printf(indent, pp.EmojiError, "Failed to read the response from %q: %v", d.url, err)
+		ppfmt.Warningf(pp.EmojiError, "Failed to read HTTP(S) response from %q: %v", d.url, err)
 		return nil
 	}
 
-	return d.extract(indent, body)
+	return d.extract(ppfmt, body)
 }
 
-func getIPFromHTTP(ctx context.Context, indent pp.Indent, url string) net.IP {
+func getIPFromHTTP(ctx context.Context, ppfmt pp.PP, url string) net.IP {
 	c := httpConn{
 		url:         url,
 		method:      http.MethodGet,
 		contentType: "",
 		accept:      "",
 		reader:      nil,
-		extract:     func(_ pp.Indent, body []byte) net.IP { return net.ParseIP(string(body)) },
+		extract:     func(_ pp.PP, body []byte) net.IP { return net.ParseIP(string(body)) },
 	}
 
-	return c.getIP(ctx, indent)
+	return c.getIP(ctx, ppfmt)
 }
 
 type HTTP struct {
@@ -76,11 +76,12 @@ func (p *HTTP) String() string {
 	return p.PolicyName
 }
 
-func (p *HTTP) GetIP(ctx context.Context, indent pp.Indent, ipNet ipnet.Type) net.IP {
+func (p *HTTP) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) net.IP {
 	url, found := p.URL[ipNet]
 	if !found {
+		ppfmt.Warningf(pp.EmojiImpossible, "Unhandled IP network: %s", ipNet.Describe())
 		return nil
 	}
 
-	return ipNet.NormalizeIP(getIPFromHTTP(ctx, indent, url))
+	return NormalizeIP(ppfmt, ipNet, getIPFromHTTP(ctx, ppfmt, url))
 }
diff --git a/internal/detector/ip.go b/internal/detector/ip.go
new file mode 100644
index 0000000..ff3916f
--- /dev/null
+++ b/internal/detector/ip.go
@@ -0,0 +1,22 @@
+package detector
+
+import (
+	"net"
+
+	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+)
+
+func NormalizeIP(ppfmt pp.PP, ipNet ipnet.Type, ip net.IP) net.IP {
+	if ip == nil {
+		return nil
+	}
+
+	val := ipNet.NormalizeIP(ip)
+	if val == nil {
+		ppfmt.Warningf(pp.EmojiError, "%q is not a valid %s address", ip, ipNet.Describe())
+		return nil
+	}
+
+	return val
+}
diff --git a/internal/detector/local.go b/internal/detector/local.go
index 883217d..ff38193 100644
--- a/internal/detector/local.go
+++ b/internal/detector/local.go
@@ -21,18 +21,19 @@ func (p *Local) String() string {
 	return p.PolicyName
 }
 
-func (p *Local) GetIP(ctx context.Context, indent pp.Indent, ipNet ipnet.Type) net.IP {
+func (p *Local) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) net.IP {
 	remoteUDPAddr, found := p.RemoteUDPAddr[ipNet]
 	if !found {
+		ppfmt.Warningf(pp.EmojiImpossible, "Unhandled IP network: %s", ipNet.Describe())
 		return nil
 	}
 
 	conn, err := net.Dial(ipNet.UDPNetwork(), remoteUDPAddr)
 	if err != nil {
-		pp.Printf(indent, pp.EmojiError, "Failed to detect a local %s address: %v", ipNet.String(), err)
+		ppfmt.Warningf(pp.EmojiError, "Failed to detect a local %s address: %v", ipNet.Describe(), err)
 		return nil
 	}
 	defer conn.Close()
 
-	return ipNet.NormalizeIP(conn.LocalAddr().(*net.UDPAddr).IP)
+	return NormalizeIP(ppfmt, ipNet, conn.LocalAddr().(*net.UDPAddr).IP)
 }
diff --git a/internal/detector/policy.go b/internal/detector/policy.go
index 523b5e9..5b9712d 100644
--- a/internal/detector/policy.go
+++ b/internal/detector/policy.go
@@ -11,5 +11,5 @@ import (
 type Policy interface {
 	IsManaged() bool
 	String() string
-	GetIP(context.Context, pp.Indent, ipnet.Type) net.IP
+	GetIP(context.Context, pp.PP, ipnet.Type) net.IP
 }
diff --git a/internal/detector/unmanaged.go b/internal/detector/unmanaged.go
index 80b6ccc..24ba854 100644
--- a/internal/detector/unmanaged.go
+++ b/internal/detector/unmanaged.go
@@ -18,7 +18,7 @@ func (p *unmanaged) String() string {
 	return "unmanaged"
 }
 
-func (p *unmanaged) GetIP(_ context.Context, _ pp.Indent, _ ipnet.Type) net.IP {
+func (p *unmanaged) GetIP(_ context.Context, _ pp.PP, _ ipnet.Type) net.IP {
 	return nil
 }
 
diff --git a/internal/file/file.go b/internal/file/file.go
index 038e9cf..7b9c9bd 100644
--- a/internal/file/file.go
+++ b/internal/file/file.go
@@ -10,10 +10,10 @@ import (
 
 var FS = os.DirFS("/") //nolint:gochecknoglobals
 
-func ReadString(indent pp.Indent, path string) (string, bool) {
+func ReadString(ppfmt pp.PP, path string) (string, bool) {
 	body, err := fs.ReadFile(FS, path)
 	if err != nil {
-		pp.Printf(indent, pp.EmojiUserError, "Failed to read %q: %v", path, err)
+		ppfmt.Errorf(pp.EmojiUserError, "Failed to read %q: %v", path, err)
 		return "", false
 	}
 
diff --git a/internal/ipnet/ipnet.go b/internal/ipnet/ipnet.go
index b7878dc..e6897c6 100644
--- a/internal/ipnet/ipnet.go
+++ b/internal/ipnet/ipnet.go
@@ -16,13 +16,13 @@ const (
 	IP6 Type = 6
 )
 
-// String returns a description of the IP network.
-func (t Type) String() string {
+// Describe returns a description of the IP network.
+func (t Type) Describe() string {
 	switch t {
 	case IP4, IP6:
 		return fmt.Sprintf("IPv%d", t)
 	default:
-		return "(unrecognized IP network)"
+		return "<unrecognized IP network>"
 	}
 }
 
diff --git a/internal/mocks/.gitignore b/internal/mocks/.gitignore
new file mode 100644
index 0000000..72e8ffc
--- /dev/null
+++ b/internal/mocks/.gitignore
@@ -0,0 +1,1 @@
+*
diff --git a/internal/pp/base.go b/internal/pp/base.go
new file mode 100644
index 0000000..3f7ad77
--- /dev/null
+++ b/internal/pp/base.go
@@ -0,0 +1,13 @@
+package pp
+
+//go:generate mockgen -destination=../mocks/mock_pp.go -package=mocks . PP
+
+type PP interface {
+	SetLevel(Level) PP
+	IsEnabledFor(Level) bool
+	IncIndent() PP
+	Infof(Emoji, string, ...interface{})
+	Noticef(Emoji, string, ...interface{})
+	Warningf(Emoji, string, ...interface{})
+	Errorf(Emoji, string, ...interface{})
+}
diff --git a/internal/pp/emoji.go b/internal/pp/emoji.go
new file mode 100644
index 0000000..75ff878
--- /dev/null
+++ b/internal/pp/emoji.go
@@ -0,0 +1,32 @@
+package pp
+
+type Emoji string
+
+const (
+	EmojiStar   Emoji = "🌟" // stars attached to the tool name
+	EmojiBullet Emoji = "🔸" // generic bullet points
+
+	EmojiEnvVars     Emoji = "📖" // reading configuration
+	EmojiConfig      Emoji = "🔧" // showing configuration
+	EmojiInternet    Emoji = "🌐" // network address detection
+	EmojiPriviledges Emoji = "🥷" // /privileges
+	EmojiMute        Emoji = "🔇" // quiet mode
+
+	EmojiAddRecord    Emoji = "🐣" // adding new DNS records
+	EmojiDelRecord    Emoji = "💀" // deleting DNS records
+	EmojiUpdateRecord Emoji = "📡" // updating DNS records
+
+	EmojiSignal      Emoji = "🚨" // catching signals
+	EmojiAlreadyDone Emoji = "🤷" // DNS records were already up to date
+	EmojiNow         Emoji = "🏃" // an event that is happening now or immediately
+	EmojiAlarm       Emoji = "⏰" // an event that is scheduled to happen, but not immediately
+	EmojiBye         Emoji = "👋" // bye!
+
+	EmojiUserError   Emoji = "😡" // configuration mistakes made by users
+	EmojiUserWarning Emoji = "😦" // warnings about possible configuration mistakes
+	EmojiError       Emoji = "😞" // errors that are not (directly) caused by user errors
+	EmojiImpossible  Emoji = "🤯" // the impossible happened
+	EmojiGood        Emoji = "👍" // everything looks good
+)
+
+const indentPrefix = "   "
diff --git a/internal/pp/fmt.go b/internal/pp/fmt.go
new file mode 100644
index 0000000..87c1ad6
--- /dev/null
+++ b/internal/pp/fmt.go
@@ -0,0 +1,74 @@
+package pp
+
+import (
+	"fmt"
+	"io"
+	"strings"
+)
+
+type formatter struct {
+	writer io.Writer
+	indent int
+	level  Level
+}
+
+func New(writer io.Writer) PP {
+	return &formatter{
+		writer: writer,
+		indent: 0,
+		level:  DefaultLevel,
+	}
+}
+
+func (f *formatter) SetLevel(lvl Level) PP {
+	return &formatter{
+		writer: f.writer,
+		indent: f.indent,
+		level:  lvl,
+	}
+}
+
+func (f *formatter) IsEnabledFor(lvl Level) bool {
+	return lvl >= f.level
+}
+
+func (f *formatter) IncIndent() PP {
+	return &formatter{
+		writer: f.writer,
+		indent: f.indent + 1,
+		level:  f.level,
+	}
+}
+
+func (f *formatter) output(lvl Level, emoji Emoji, msg string) {
+	if lvl < f.level {
+		return
+	}
+
+	line := fmt.Sprintf("%s%s %s",
+		strings.Repeat(indentPrefix, f.indent),
+		string(emoji),
+		msg)
+	line = strings.TrimSuffix(line, "\n")
+	fmt.Fprintln(f.writer, line)
+}
+
+func (f *formatter) printf(lvl Level, emoji Emoji, format string, args ...interface{}) {
+	f.output(lvl, emoji, fmt.Sprintf(format, args...))
+}
+
+func (f *formatter) Infof(emoji Emoji, format string, args ...interface{}) {
+	f.printf(Info, emoji, format, args...)
+}
+
+func (f *formatter) Noticef(emoji Emoji, format string, args ...interface{}) {
+	f.printf(Notice, emoji, format, args...)
+}
+
+func (f *formatter) Warningf(emoji Emoji, format string, args ...interface{}) {
+	f.printf(Warning, emoji, format, args...)
+}
+
+func (f *formatter) Errorf(emoji Emoji, format string, args ...interface{}) {
+	f.printf(Error, emoji, format, args...)
+}
diff --git a/internal/pp/level.go b/internal/pp/level.go
new file mode 100644
index 0000000..78fcc13
--- /dev/null
+++ b/internal/pp/level.go
@@ -0,0 +1,14 @@
+package pp
+
+type Level int
+
+const (
+	Debug        Level = iota // debugging info, currently not used
+	Info                      // information not about actual actions
+	Notice                    // information about actual actions, but not an error
+	Warning                   // non-fatal errors where the program should continue updating IP addresses
+	Error                     // fatal errors where the program should stop
+	DefaultLevel = Info
+	Verbose      = Info
+	Quiet        = Notice
+)
diff --git a/internal/pp/pp.go b/internal/pp/pp.go
deleted file mode 100644
index 007b07b..0000000
--- a/internal/pp/pp.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package pp
-
-import (
-	"fmt"
-	"strings"
-)
-
-type Indent int
-
-const NoIndent Indent = 0
-
-func (i Indent) Succ() Indent {
-	return i + 1
-}
-
-type Emoji string
-
-const (
-	EmojiStar   Emoji = "🌟" // stars attached to the tool name
-	EmojiBullet Emoji = "🔸" // generic bullet points
-
-	EmojiEnvVars     Emoji = "📖" // reading configuration
-	EmojiConfig      Emoji = "🔧" // showing configuration
-	EmojiInternet    Emoji = "🌐" // network address detection
-	EmojiPriviledges Emoji = "🥷" // /privileges
-	EmojiMute        Emoji = "🔇" // quiet mode
-
-	EmojiAddRecord    Emoji = "🐣" // adding new DNS records
-	EmojiDelRecord    Emoji = "💀" // deleting DNS records
-	EmojiUpdateRecord Emoji = "📡" // updating DNS records
-
-	EmojiSignal      Emoji = "🚨" // catching signals
-	EmojiAlreadyDone Emoji = "🤷" // DNS records were already up to date
-	EmojiNow         Emoji = "🏃" // an event that is happening now or immediately
-	EmojiAlarm       Emoji = "⏰" // an event that is scheduled to happen, but not immediately
-	EmojiBye         Emoji = "👋" // bye!
-
-	EmojiUserError   Emoji = "😡" // configuration mistakes made by users
-	EmojiUserWarning Emoji = "😦" // warnings about possible configuration mistakes
-	EmojiError       Emoji = "😞" // errors that are not (directly) caused by user errors
-	EmojiImpossible  Emoji = "🤯" // the impossible happened
-	EmojiGood        Emoji = "👍" // everything looks good
-)
-
-func (e Emoji) String() string {
-	return string(e)
-}
-
-const IndentPrefix = "   "
-
-func prefix(indent Indent, emoji Emoji, msg string) string {
-	return fmt.Sprintf("%s%s %s", strings.Repeat(IndentPrefix, int(indent)), emoji, msg)
-}
-
-func printString(indent Indent, emoji Emoji, msg string) {
-	buf := []byte(prefix(indent, emoji, msg))
-	if len(buf) == 0 || buf[len(buf)-1] != '\n' {
-		buf = append(buf, '\n')
-	}
-	fmt.Printf("%s", buf) //nolint:forbidigo
-}
-
-func Print(indent Indent, emoji Emoji, args ...interface{}) {
-	if false { // enable go vet printf checking
-		_ = fmt.Sprint(args...)
-	}
-
-	printString(indent, emoji, fmt.Sprint(args...))
-}
-
-func Printf(indent Indent, emoji Emoji, format string, args ...interface{}) {
-	if false { // enable go vet printf checking
-		_ = fmt.Sprintf(format, args...)
-	}
-
-	printString(indent, emoji, fmt.Sprintf(format, args...))
-}
-
-func TopPrint(emoji Emoji, args ...interface{}) {
-	if false { // enable go vet printf checking
-		_ = fmt.Sprint(args...)
-	}
-
-	Print(NoIndent, emoji, args...)
-}
-
-func TopPrintf(emoji Emoji, format string, args ...interface{}) {
-	if false { // enable go vet printf checking
-		_ = fmt.Sprintf(format, args...)
-	}
-
-	Printf(NoIndent, emoji, format, args...)
-}
diff --git a/internal/quiet/quiet.go b/internal/quiet/quiet.go
deleted file mode 100644
index 3a4badc..0000000
--- a/internal/quiet/quiet.go
+++ /dev/null
@@ -1,8 +0,0 @@
-package quiet
-
-type Quiet bool
-
-const (
-	QUIET   = Quiet(true)
-	VERBOSE = Quiet(false)
-)
diff --git a/internal/updator/updator.go b/internal/updator/updator.go
index 67186c8..7ac5b60 100644
--- a/internal/updator/updator.go
+++ b/internal/updator/updator.go
@@ -8,7 +8,6 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/api"
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
-	"github.com/favonia/cloudflare-ddns/internal/quiet"
 )
 
 // Args is the type of (named) arguments to updateRecords.
@@ -40,12 +39,13 @@ func splitRecords(rmap map[string]net.IP, target net.IP) (matchedIDs, unmatchedI
 	return matchedIDs, unmatchedIDs
 }
 
-func Do(ctx context.Context, indent pp.Indent, quiet quiet.Quiet, args *Args) bool { //nolint:funlen,cyclop,gocognit
+func Do(ctx context.Context, ppfmt pp.PP, args *Args) bool { //nolint:funlen,cyclop,gocognit
 	recordType := args.IPNetwork.RecordType()
+	domainDescription := args.Domain.Describe()
 
-	rs, ok := args.Handle.ListRecords(ctx, indent, args.Domain, args.IPNetwork)
+	rs, ok := args.Handle.ListRecords(ctx, ppfmt, args.Domain, args.IPNetwork)
 	if !ok {
-		pp.Printf(indent, pp.EmojiError, "Failed to update %s records of %q.", recordType, args.Domain.Describe())
+		ppfmt.Errorf(pp.EmojiError, "Failed to (fully) update %s records of %q", recordType, domainDescription)
 		return false
 	}
 
@@ -67,11 +67,7 @@ func Do(ctx context.Context, indent pp.Indent, quiet quiet.Quiet, args *Args) bo
 	}
 
 	if uptodate && len(matchedIDs) == 0 && len(unmatchedIDs) == 0 {
-		if !quiet {
-			pp.Printf(indent, pp.EmojiAlreadyDone, "The %s records of %q are already up to date.",
-				recordType, args.Domain.Describe())
-		}
-
+		ppfmt.Infof(pp.EmojiAlreadyDone, "The %s records of %q are already up to date", recordType, domainDescription)
 		return true
 	}
 
@@ -79,9 +75,9 @@ func Do(ctx context.Context, indent pp.Indent, quiet quiet.Quiet, args *Args) bo
 		var unhandled []string
 
 		for i, id := range unmatchedIDs {
-			if args.Handle.UpdateRecord(ctx, indent, args.Domain, args.IPNetwork, id, args.IP) {
-				pp.Printf(indent, pp.EmojiUpdateRecord,
-					"Updated a stale %s record of %q (ID: %s).", recordType, args.Domain.Describe(), id)
+			if args.Handle.UpdateRecord(ctx, ppfmt, args.Domain, args.IPNetwork, id, args.IP) {
+				ppfmt.Noticef(pp.EmojiUpdateRecord,
+					"Updated a stale %s record of %q (ID: %s)", recordType, domainDescription, id)
 
 				uptodate = true
 				numUnmatched--
@@ -89,9 +85,9 @@ func Do(ctx context.Context, indent pp.Indent, quiet quiet.Quiet, args *Args) bo
 
 				break
 			} else {
-				if args.Handle.DeleteRecord(ctx, indent, args.Domain, args.IPNetwork, id) {
-					pp.Printf(indent, pp.EmojiDelRecord,
-						"Deleted a stale %s record of %q instead (ID: %s).", recordType, args.Domain.Describe(), id)
+				if args.Handle.DeleteRecord(ctx, ppfmt, args.Domain, args.IPNetwork, id) {
+					ppfmt.Noticef(pp.EmojiDelRecord, "Deleted a stale %s record of %q (ID: %s)",
+						recordType, domainDescription, id)
 					numUnmatched--
 				}
 				continue
@@ -102,32 +98,29 @@ func Do(ctx context.Context, indent pp.Indent, quiet quiet.Quiet, args *Args) bo
 	}
 
 	if !uptodate && args.IP != nil {
-		if id, ok := args.Handle.CreateRecord(ctx, indent,
-			args.Domain, args.IPNetwork, args.IP, args.TTL.Int(), args.Proxied); ok {
-			pp.Printf(indent, pp.EmojiAddRecord,
-				"Added a new %s record of %q (ID: %s).", recordType, args.Domain.Describe(), id)
+		if id, ok := args.Handle.CreateRecord(ctx, ppfmt,
+			args.Domain, args.IPNetwork, args.IP, args.TTL, args.Proxied); ok {
+			ppfmt.Noticef(pp.EmojiAddRecord, "Added a new %s record of %q (ID: %s)", recordType, domainDescription, id)
 			uptodate = true
 		}
 	}
 
 	for _, id := range unmatchedIDs {
-		if args.Handle.DeleteRecord(ctx, indent, args.Domain, args.IPNetwork, id) {
-			pp.Printf(indent, pp.EmojiDelRecord,
-				"Deleted a stale %s record of %q (ID: %s).", recordType, args.Domain.Describe(), id)
+		if args.Handle.DeleteRecord(ctx, ppfmt, args.Domain, args.IPNetwork, id) {
+			ppfmt.Noticef(pp.EmojiDelRecord, "Deleted a stale %s record of %q (ID: %s)", recordType, domainDescription, id)
 			numUnmatched--
 		}
 	}
 
 	for _, id := range matchedIDs {
-		if args.Handle.DeleteRecord(ctx, indent, args.Domain, args.IPNetwork, id) {
-			pp.Printf(indent, pp.EmojiDelRecord,
-				"Deleted a duplicate %s record of %q (ID: %s).", recordType, args.Domain.Describe(), id)
+		if args.Handle.DeleteRecord(ctx, ppfmt, args.Domain, args.IPNetwork, id) {
+			ppfmt.Noticef(pp.EmojiDelRecord, "Deleted a duplicate %s record of %q (ID: %s)",
+				recordType, domainDescription, id)
 		}
 	}
 
 	if !uptodate || numUnmatched > 0 {
-		pp.Printf(indent, pp.EmojiError,
-			"Failed to update %s records of %q.", recordType, args.Domain.Describe())
+		ppfmt.Errorf(pp.EmojiError, "Failed to (fully) update %s records of %q", recordType, domainDescription)
 		return false
 	}
 


Test patch:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index fc0337d..b3c5877 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -23,7 +23,10 @@ jobs:
         with:
           go-version: ${{ matrix.go-version }}
       - uses: actions/checkout@v2
-      - run: go test -coverpkg=./... -coverprofile=coverage.txt -race -v ./...
+      - run: |
+          go install github.com/golang/mock/mockgen@latest
+          go generate ./...
+          go test -coverpkg=./... -coverprofile=coverage.txt -race -v ./...
       - uses: codecov/codecov-action@v2
         with:
           file: ./coverage.txt
diff --git a/internal/api/cloudflare_test.go b/internal/api/cloudflare_test.go
index 68d5af6..805e0f6 100644
--- a/internal/api/cloudflare_test.go
+++ b/internal/api/cloudflare_test.go
@@ -14,11 +14,14 @@ import (
 	"time"
 
 	"github.com/cloudflare/cloudflare-go"
+	"github.com/golang/mock/gomock"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
 	"github.com/favonia/cloudflare-ddns/internal/api"
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/mocks"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
 // mockID returns a hex string of length 32, suitable for all kinds of IDs
@@ -42,9 +45,12 @@ const (
 	mockAccount = "account456"
 )
 
-func newServerAuth() (*httptest.Server, *http.ServeMux, *api.CloudflareAuth) {
+func newServerAuth(t *testing.T) (*http.ServeMux, *api.CloudflareAuth) {
+	t.Helper()
+
 	mux := http.NewServeMux()
 	ts := httptest.NewServer(mux)
+	t.Cleanup(ts.Close)
 
 	auth := api.CloudflareAuth{
 		Token:     mockToken,
@@ -52,7 +58,7 @@ func newServerAuth() (*httptest.Server, *http.ServeMux, *api.CloudflareAuth) {
 		BaseURL:   ts.URL,
 	}
 
-	return ts, mux, &auth
+	return mux, &auth
 }
 
 func handleTokensVerify(t *testing.T, w http.ResponseWriter, r *http.Request) {
@@ -79,54 +85,57 @@ func handleTokensVerify(t *testing.T, w http.ResponseWriter, r *http.Request) {
 		mockID("result", 0))
 }
 
-func newHandle(t *testing.T) (*httptest.Server, *http.ServeMux, api.Handle) {
+func newHandle(t *testing.T) (*http.ServeMux, api.Handle) {
 	t.Helper()
+	mockCtrl := gomock.NewController(t)
 
-	ts, mux, auth := newServerAuth()
+	mux, auth := newServerAuth(t)
 
 	mux.HandleFunc("/user/tokens/verify", func(w http.ResponseWriter, r *http.Request) {
 		handleTokensVerify(t, w, r)
 	})
 
-	h, ok := auth.New(context.Background(), 3, time.Second)
+	mockPP := mocks.NewMockPP(mockCtrl)
+	h, ok := auth.New(context.Background(), mockPP, time.Second)
 	require.True(t, ok)
 	require.NotNil(t, h)
 
-	return ts, mux, h
+	return mux, h
 }
 
 func TestNewValid(t *testing.T) {
 	t.Parallel()
 
-	ts, _, _ := newHandle(t)
-	defer ts.Close()
+	_, _ = newHandle(t)
 }
 
 func TestNewEmpty(t *testing.T) {
 	t.Parallel()
+	mockCtrl := gomock.NewController(t)
 
-	ts, _, auth := newServerAuth()
-	defer ts.Close()
+	_, auth := newServerAuth(t)
 
 	auth.Token = ""
-	h, ok := auth.New(context.Background(), 3, time.Second)
+	mockPP := mocks.NewMockPP(mockCtrl)
+	mockPP.EXPECT().Errorf(pp.EmojiUserError, "Failed to prepare the Cloudflare authentication: %v", gomock.Any())
+	h, ok := auth.New(context.Background(), mockPP, time.Second)
 	require.False(t, ok)
 	require.Nil(t, h)
 }
 
 func TestNewInvalid(t *testing.T) {
 	t.Parallel()
+	mockCtrl := gomock.NewController(t)
 
-	ts, mux, auth := newServerAuth()
-	defer ts.Close()
+	mux, auth := newServerAuth(t)
 
 	mux.HandleFunc("/user/tokens/verify", func(w http.ResponseWriter, r *http.Request) {
 		assert.Equal(t, http.MethodGet, r.Method)
 		assert.Equal(t, []string{fmt.Sprintf("Bearer %s", mockToken)}, r.Header["Authorization"])
 		assert.Empty(t, r.URL.Query())
 
-		w.WriteHeader(http.StatusUnauthorized)
 		w.Header().Set("content-type", "application/json")
+		w.WriteHeader(http.StatusUnauthorized)
 		fmt.Fprintf(w,
 			`{
 				"success": false,
@@ -136,7 +145,12 @@ func TestNewInvalid(t *testing.T) {
 			}`)
 	})
 
-	h, ok := auth.New(context.Background(), 3, time.Second)
+	mockPP := mocks.NewMockPP(mockCtrl)
+	gomock.InOrder(
+		mockPP.EXPECT().Errorf(pp.EmojiUserError, "The Cloudflare API token is not valid: %v", gomock.Any()),
+		mockPP.EXPECT().Errorf(pp.EmojiUserError, "Please double-check CF_API_TOKEN or CF_API_TOKEN_FILE"),
+	)
+	h, ok := auth.New(context.Background(), mockPP, time.Second)
 	require.False(t, ok)
 	require.Nil(t, h)
 }
@@ -151,7 +165,7 @@ func mockZone(zoneName string, i int) *cloudflare.Zone {
 
 func mockZonesResponse(zoneName string, numZones int) *cloudflare.ZonesResponse {
 	if numZones > 50 {
-		panic("mockZonesResponse got too many zone names.")
+		panic("mockZonesResponse got too many zone names")
 	}
 
 	zones := make([]cloudflare.Zone, numZones)
@@ -236,38 +250,48 @@ func (h *zonesHandler) isExhausted() bool {
 
 func TestActiveZonesRoot(t *testing.T) {
 	t.Parallel()
+	mockCtrl := gomock.NewController(t)
 
-	ts, _, h := newHandle(t)
-	defer ts.Close()
+	_, h := newHandle(t)
 
-	zones, ok := h.(*api.CloudflareHandle).ActiveZones(context.Background(), 3, "")
+	mockPP := mocks.NewMockPP(mockCtrl)
+	zones, ok := h.(*api.CloudflareHandle).ActiveZones(context.Background(), mockPP, "")
 	require.True(t, ok)
 	require.Empty(t, zones)
 }
 
 func TestActiveZonesTwo(t *testing.T) {
 	t.Parallel()
+	mockCtrl := gomock.NewController(t)
 
-	ts, mux, h := newHandle(t)
-	defer ts.Close()
+	mux, h := newHandle(t)
 
 	zh := newZonesHandler(t, mux)
 
 	zh.set(map[string]int{"test.org": 2}, 1)
-	zones, ok := h.(*api.CloudflareHandle).ActiveZones(context.Background(), 3, "test.org")
+	mockPP := mocks.NewMockPP(mockCtrl)
+	zones, ok := h.(*api.CloudflareHandle).ActiveZones(context.Background(), mockPP, "test.org")
 	require.True(t, ok)
 	require.Equal(t, mockIDs("test.org", 0, 1), zones)
 	require.True(t, zh.isExhausted())
 
 	zh.set(nil, 0)
-	zones, ok = h.(*api.CloudflareHandle).ActiveZones(context.Background(), 3, "test.org")
+	mockPP = mocks.NewMockPP(mockCtrl)
+	zones, ok = h.(*api.CloudflareHandle).ActiveZones(context.Background(), mockPP, "test.org")
 	require.True(t, ok)
 	require.Equal(t, mockIDs("test.org", 0, 1), zones)
 	require.True(t, zh.isExhausted())
 
 	h.FlushCache()
 
-	zones, ok = h.(*api.CloudflareHandle).ActiveZones(context.Background(), 3, "test.org")
+	mockPP = mocks.NewMockPP(mockCtrl)
+	mockPP.EXPECT().Warningf(
+		pp.EmojiError,
+		"Failed to check the existence of a zone named %q: %v",
+		"test.org",
+		gomock.Any(),
+	)
+	zones, ok = h.(*api.CloudflareHandle).ActiveZones(context.Background(), mockPP, "test.org")
 	require.False(t, ok)
 	require.Nil(t, zones)
 	require.True(t, zh.isExhausted())
@@ -275,109 +299,124 @@ func TestActiveZonesTwo(t *testing.T) {
 
 func TestActiveZonesEmpty(t *testing.T) {
 	t.Parallel()
+	mockCtrl := gomock.NewController(t)
 
-	ts, mux, h := newHandle(t)
-	defer ts.Close()
+	mux, h := newHandle(t)
 
 	zh := newZonesHandler(t, mux)
 
 	zh.set(map[string]int{}, 1)
-	zones, ok := h.(*api.CloudflareHandle).ActiveZones(context.Background(), 3, "test.org")
+	mockPP := mocks.NewMockPP(mockCtrl)
+	zones, ok := h.(*api.CloudflareHandle).ActiveZones(context.Background(), mockPP, "test.org")
 	require.True(t, ok)
 	require.Empty(t, zones)
 	require.True(t, zh.isExhausted())
 
 	zh.set(nil, 0) // this should not affect the result due to the caching
-	zones, ok = h.(*api.CloudflareHandle).ActiveZones(context.Background(), 3, "test.org")
+	mockPP = mocks.NewMockPP(mockCtrl)
+	zones, ok = h.(*api.CloudflareHandle).ActiveZones(context.Background(), mockPP, "test.org")
 	require.True(t, ok)
 	require.Empty(t, zones)
 	require.True(t, zh.isExhausted())
 
 	h.FlushCache()
 
-	zones, ok = h.(*api.CloudflareHandle).ActiveZones(context.Background(), 3, "test.org")
+	mockPP = mocks.NewMockPP(mockCtrl)
+	mockPP.EXPECT().Warningf(
+		pp.EmojiError,
+		"Failed to check the existence of a zone named %q: %v",
+		"test.org",
+		gomock.Any(),
+	)
+	zones, ok = h.(*api.CloudflareHandle).ActiveZones(context.Background(), mockPP, "test.org")
 	require.False(t, ok)
 	require.Nil(t, zones)
 	require.True(t, zh.isExhausted())
 }
 
-func TestZoneOfDomainRoot(t *testing.T) {
-	t.Parallel()
-
-	ts, mux, h := newHandle(t)
-	defer ts.Close()
-
-	zh := newZonesHandler(t, mux)
-
-	zh.set(map[string]int{"test.org": 1}, 1)
-	zoneID, ok := h.(*api.CloudflareHandle).ZoneOfDomain(context.Background(), 3, "test.org")
-	require.True(t, ok)
-	require.Equal(t, mockID("test.org", 0), zoneID)
-	require.True(t, zh.isExhausted())
-
-	zh.set(nil, 0)
-	zoneID, ok = h.(*api.CloudflareHandle).ZoneOfDomain(context.Background(), 3, "test.org")
-	require.True(t, ok)
-	require.Equal(t, mockID("test.org", 0), zoneID)
-}
-
-func TestZoneOfDomainOneLevel(t *testing.T) {
-	t.Parallel()
-
-	ts, mux, h := newHandle(t)
-	defer ts.Close()
-
-	zh := newZonesHandler(t, mux)
-
-	zh.set(map[string]int{"test.org": 1}, 2)
-	zoneID, ok := h.(*api.CloudflareHandle).ZoneOfDomain(context.Background(), 3, "sub.test.org")
-	require.True(t, ok)
-	require.Equal(t, mockID("test.org", 0), zoneID)
-	require.True(t, zh.isExhausted())
-
-	zh.set(nil, 0)
-	zoneID, ok = h.(*api.CloudflareHandle).ZoneOfDomain(context.Background(), 3, "sub.test.org")
-	require.True(t, ok)
-	require.Equal(t, mockID("test.org", 0), zoneID)
-}
-
-func TestZoneOfDomainNone(t *testing.T) {
-	t.Parallel()
-
-	ts, mux, h := newHandle(t)
-	defer ts.Close()
-
-	zh := newZonesHandler(t, mux)
-
-	zh.set(map[string]int{}, 3)
-	zoneID, ok := h.(*api.CloudflareHandle).ZoneOfDomain(context.Background(), 3, "sub.test.org")
-	require.False(t, ok)
-	require.Equal(t, "", zoneID)
-	require.True(t, zh.isExhausted())
-}
-
-func TestZoneOfDomainMultiple(t *testing.T) {
+//nolint:funlen
+func TestZoneOfDomain(t *testing.T) {
 	t.Parallel()
 
-	ts, mux, h := newHandle(t)
-	defer ts.Close()
-
-	zh := newZonesHandler(t, mux)
-
-	zh.set(map[string]int{"test.org": 2}, 2)
-	zoneID, ok := h.(*api.CloudflareHandle).ZoneOfDomain(context.Background(), 3, "sub.test.org")
-	require.False(t, ok)
-	require.Equal(t, "", zoneID)
-	require.True(t, zh.isExhausted())
+	for name, tc := range map[string]struct {
+		zone          string
+		domain        api.FQDN
+		numZones      map[string]int
+		accessCount   int
+		expected      string
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
+	}{
+		"root": {"test.org", "test.org", map[string]int{"test.org": 1}, 1, mockID("test.org", 0), true, nil},
+		"one":  {"test.org", "sub.test.org", map[string]int{"test.org": 1}, 2, mockID("test.org", 0), true, nil},
+		"none": {
+			"test.org", "sub.test.org",
+			map[string]int{},
+			3, "", false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(pp.EmojiError, "Failed to find the zone of %q", "sub.test.org")
+			},
+		},
+		"multiple": {
+			"test.org", "sub.test.org",
+			map[string]int{"test.org": 2},
+			2, "", false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(
+					pp.EmojiImpossible,
+					"Found multiple active zones named %q. Specifying CF_ACCOUNT_ID might help",
+					"test.org",
+				)
+			},
+		},
+	} {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+			mockCtrl := gomock.NewController(t)
+			mux, h := newHandle(t)
+
+			zh := newZonesHandler(t, mux)
+
+			zh.set(tc.numZones, tc.accessCount)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			zoneID, ok := h.(*api.CloudflareHandle).ZoneOfDomain(context.Background(), mockPP, tc.domain)
+			require.Equal(t, tc.ok, ok)
+			require.Equal(t, tc.expected, zoneID)
+			require.True(t, zh.isExhausted())
+
+			if tc.ok {
+				zh.set(nil, 0)
+				mockPP = mocks.NewMockPP(mockCtrl)
+				if tc.prepareMockPP != nil {
+					tc.prepareMockPP(mockPP)
+				}
+				zoneID, ok = h.(*api.CloudflareHandle).ZoneOfDomain(context.Background(), mockPP, tc.domain)
+				require.Equal(t, tc.ok, ok)
+				require.Equal(t, tc.expected, zoneID)
+				require.True(t, zh.isExhausted())
+			}
+		})
+	}
 }
 
 func TestZoneOfDomainInvalid(t *testing.T) {
 	t.Parallel()
+	mockCtrl := gomock.NewController(t)
 
-	ts, _, h := newHandle(t)
-	defer ts.Close()
+	_, h := newHandle(t)
 
-	zoneID, ok := h.(*api.CloudflareHandle).ZoneOfDomain(context.Background(), 3, "sub.test.org")
+	mockPP := mocks.NewMockPP(mockCtrl)
+	mockPP.EXPECT().Warningf(
+		pp.EmojiError,
+		"Failed to check the existence of a zone named %q: %v",
+		"sub.test.org",
+		gomock.Any(),
+	)
+	zoneID, ok := h.(*api.CloudflareHandle).ZoneOfDomain(context.Background(), mockPP, "sub.test.org")
 	require.False(t, ok)
 	require.Equal(t, "", zoneID)
 }
@@ -391,10 +430,10 @@ func mockDNSRecord(id string, ipNet ipnet.Type, domain api.FQDN, ip net.IP) *clo
 	}
 }
 
-//nolint: unparam // domain always = "test.org" for now
+//nolint:unparam // domain always = "test.org" for now
 func mockDNSListResponse(ipNet ipnet.Type, domain api.FQDN, ips map[string]net.IP) *cloudflare.DNSListResponse {
 	if len(ips) > 100 {
-		panic("mockDNSResponse got too many IPs.")
+		panic("mockDNSResponse got too many IPs")
 	}
 
 	rs := make([]cloudflare.DNSRecord, 0, len(ips))
@@ -423,9 +462,9 @@ func mockDNSListResponse(ipNet ipnet.Type, domain api.FQDN, ips map[string]net.I
 
 func TestListRecords(t *testing.T) {
 	t.Parallel()
+	mockCtrl := gomock.NewController(t)
 
-	ts, mux, h := newHandle(t)
-	defer ts.Close()
+	mux, h := newHandle(t)
 
 	zh := newZonesHandler(t, mux)
 	zh.set(map[string]int{"test.org": 1}, 2)
@@ -459,46 +498,66 @@ func TestListRecords(t *testing.T) {
 
 	expected := map[string]net.IP{"record1": net.ParseIP("::1"), "record2": net.ParseIP("::2")}
 	ipNet, ips, accessCount = ipnet.IP6, expected, 1
-	ips, ok := h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP6)
+	mockPP := mocks.NewMockPP(mockCtrl)
+	ips, ok := h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP6)
 	require.True(t, ok)
 	require.Equal(t, expected, ips)
 	require.Equal(t, 0, accessCount)
 
 	// testing the caching
-	ips, ok = h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP6)
+	mockPP = mocks.NewMockPP(mockCtrl)
+	ips, ok = h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP6)
 	require.True(t, ok)
 	require.Equal(t, expected, ips)
 }
 
 func TestListRecordsInvalidDomain(t *testing.T) {
 	t.Parallel()
+	mockCtrl := gomock.NewController(t)
 
-	ts, mux, h := newHandle(t)
-	defer ts.Close()
+	mux, h := newHandle(t)
 
 	zh := newZonesHandler(t, mux)
 	zh.set(map[string]int{"test.org": 1}, 2)
 
-	ips, ok := h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP4)
+	mockPP := mocks.NewMockPP(mockCtrl)
+	mockPP.EXPECT().Warningf(pp.EmojiError, "Failed to retrieve records of %q: %v", "sub.test.org", gomock.Any())
+	ips, ok := h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP4)
 	require.False(t, ok)
 	require.Nil(t, ips)
 
-	ips, ok = h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP6)
+	mockPP = mocks.NewMockPP(mockCtrl)
+	mockPP.EXPECT().Warningf(pp.EmojiError, "Failed to retrieve records of %q: %v", "sub.test.org", gomock.Any())
+	ips, ok = h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP6)
 	require.False(t, ok)
 	require.Nil(t, ips)
 }
 
 func TestListRecordsInvalidZone(t *testing.T) {
 	t.Parallel()
+	mockCtrl := gomock.NewController(t)
 
-	ts, _, h := newHandle(t)
-	defer ts.Close()
+	_, h := newHandle(t)
 
-	ips, ok := h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP4)
+	mockPP := mocks.NewMockPP(mockCtrl)
+	mockPP.EXPECT().Warningf(
+		pp.EmojiError,
+		"Failed to check the existence of a zone named %q: %v",
+		"sub.test.org",
+		gomock.Any(),
+	)
+	ips, ok := h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP4)
 	require.False(t, ok)
 	require.Nil(t, ips)
 
-	ips, ok = h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP6)
+	mockPP = mocks.NewMockPP(mockCtrl)
+	mockPP.EXPECT().Warningf(
+		pp.EmojiError,
+		"Failed to check the existence of a zone named %q: %v",
+		"sub.test.org",
+		gomock.Any(),
+	)
+	ips, ok = h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP6)
 	require.False(t, ok)
 	require.Nil(t, ips)
 }
@@ -527,11 +586,11 @@ func mockDNSRecordResponse(id string, ipNet ipnet.Type, domain api.FQDN, ip net.
 	return envelopDNSRecordResponse(mockDNSRecord(id, ipNet, domain, ip))
 }
 
-func TestDeleteRecordsValid(t *testing.T) {
+func TestDeleteRecordValid(t *testing.T) {
 	t.Parallel()
+	mockCtrl := gomock.NewController(t)
 
-	ts, mux, h := newHandle(t)
-	defer ts.Close()
+	mux, h := newHandle(t)
 
 	zh := newZonesHandler(t, mux)
 	zh.set(map[string]int{"test.org": 1}, 2)
@@ -570,46 +629,60 @@ func TestDeleteRecordsValid(t *testing.T) {
 		})
 
 	deleteAccessCount = 1
-	ok := h.DeleteRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, "record1")
+	mockPP := mocks.NewMockPP(mockCtrl)
+	ok := h.DeleteRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, "record1")
 	require.True(t, ok)
 
 	listAccessCount, deleteAccessCount = 1, 1
-	_, _ = h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP6)
-	_ = h.DeleteRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, "record1")
-	rs, ok := h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP6)
+	mockPP = mocks.NewMockPP(mockCtrl)
+	_, _ = h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP6)
+	_ = h.DeleteRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, "record1")
+	rs, ok := h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP6)
 	require.True(t, ok)
 	require.Empty(t, rs)
 }
 
-func TestDeleteRecordsInvalid(t *testing.T) {
+func TestDeleteRecordInvalid(t *testing.T) {
 	t.Parallel()
+	mockCtrl := gomock.NewController(t)
 
-	ts, mux, h := newHandle(t)
-	defer ts.Close()
+	mux, h := newHandle(t)
 
 	zh := newZonesHandler(t, mux)
 	zh.set(map[string]int{"test.org": 1}, 2)
 
-	ok := h.DeleteRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, "record1")
+	mockPP := mocks.NewMockPP(mockCtrl)
+	mockPP.EXPECT().Warningf(pp.EmojiError, "Failed to delete a stale %s record of %q (ID: %s): %v",
+		"AAAA",
+		"sub.test.org",
+		"record1",
+		gomock.Any(),
+	)
+	ok := h.DeleteRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, "record1")
 	require.False(t, ok)
 }
 
-func TestDeleteRecordsZoneInvalid(t *testing.T) {
+func TestDeleteRecordZoneInvalid(t *testing.T) {
 	t.Parallel()
+	mockCtrl := gomock.NewController(t)
 
-	ts, _, h := newHandle(t)
-	defer ts.Close()
+	_, h := newHandle(t)
 
-	ok := h.DeleteRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, "record1")
+	mockPP := mocks.NewMockPP(mockCtrl)
+	mockPP.EXPECT().Warningf(pp.EmojiError, "Failed to check the existence of a zone named %q: %v",
+		"sub.test.org",
+		gomock.Any(),
+	)
+	ok := h.DeleteRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, "record1")
 	require.False(t, ok)
 }
 
-//nolint: funlen
+//nolint:funlen
 func TestUpdateRecordValid(t *testing.T) {
 	t.Parallel()
+	mockCtrl := gomock.NewController(t)
 
-	ts, mux, h := newHandle(t)
-	defer ts.Close()
+	mux, h := newHandle(t)
 
 	zh := newZonesHandler(t, mux)
 	zh.set(map[string]int{"test.org": 1}, 2)
@@ -658,46 +731,60 @@ func TestUpdateRecordValid(t *testing.T) {
 		})
 
 	updateAccessCount = 1
-	ok := h.UpdateRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, "record1", net.ParseIP("::2"))
+	mockPP := mocks.NewMockPP(mockCtrl)
+	ok := h.UpdateRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, "record1", net.ParseIP("::2"))
 	require.True(t, ok)
 
 	listAccessCount, updateAccessCount = 1, 1
-	_, _ = h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP6)
-	_ = h.UpdateRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, "record1", net.ParseIP("::2"))
-	rs, ok := h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP6)
+	mockPP = mocks.NewMockPP(mockCtrl)
+	_, _ = h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP6)
+	_ = h.UpdateRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, "record1", net.ParseIP("::2"))
+	rs, ok := h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP6)
 	require.True(t, ok)
 	require.Equal(t, map[string]net.IP{"record1": net.ParseIP("::2")}, rs)
 }
 
 func TestUpdateRecordInvalid(t *testing.T) {
 	t.Parallel()
+	mockCtrl := gomock.NewController(t)
 
-	ts, mux, h := newHandle(t)
-	defer ts.Close()
+	mux, h := newHandle(t)
 
 	zh := newZonesHandler(t, mux)
 	zh.set(map[string]int{"test.org": 1}, 2)
 
-	ok := h.UpdateRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, "record1", net.ParseIP("::1"))
+	mockPP := mocks.NewMockPP(mockCtrl)
+	mockPP.EXPECT().Warningf(pp.EmojiError, "Failed to update a stale %s record of %q (ID: %s): %v",
+		"AAAA",
+		"sub.test.org",
+		"record1",
+		gomock.Any(),
+	)
+	ok := h.UpdateRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, "record1", net.ParseIP("::1"))
 	require.False(t, ok)
 }
 
 func TestUpdateRecordInvalidZone(t *testing.T) {
 	t.Parallel()
+	mockCtrl := gomock.NewController(t)
 
-	ts, _, h := newHandle(t)
-	defer ts.Close()
+	_, h := newHandle(t)
 
-	ok := h.UpdateRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, "record1", net.ParseIP("::1"))
+	mockPP := mocks.NewMockPP(mockCtrl)
+	mockPP.EXPECT().Warningf(pp.EmojiError, "Failed to check the existence of a zone named %q: %v",
+		"sub.test.org",
+		gomock.Any(),
+	)
+	ok := h.UpdateRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, "record1", net.ParseIP("::1"))
 	require.False(t, ok)
 }
 
-//nolint: funlen
+//nolint:funlen
 func TestCreateRecordValid(t *testing.T) {
 	t.Parallel()
+	mockCtrl := gomock.NewController(t)
 
-	ts, mux, h := newHandle(t)
-	defer ts.Close()
+	mux, h := newHandle(t)
 
 	zh := newZonesHandler(t, mux)
 	zh.set(map[string]int{"test.org": 1}, 2)
@@ -747,39 +834,52 @@ func TestCreateRecordValid(t *testing.T) {
 		})
 
 	createAccessCount = 1
-	actualID, ok := h.CreateRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, net.ParseIP("::1"), 100, false)
+	mockPP := mocks.NewMockPP(mockCtrl)
+	actualID, ok := h.CreateRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, net.ParseIP("::1"), 100, false) //nolint:lll
 	require.True(t, ok)
 	require.Equal(t, "record1", actualID)
 
 	listAccessCount, createAccessCount = 1, 1
-	_, _ = h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP6)
-	_, _ = h.CreateRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, net.ParseIP("::1"), 100, false)
-	rs, ok := h.ListRecords(context.Background(), 3, "sub.test.org", ipnet.IP6)
+	mockPP = mocks.NewMockPP(mockCtrl)
+	_, _ = h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP6)
+	_, _ = h.CreateRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, net.ParseIP("::1"), 100, false)
+	rs, ok := h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP6)
 	require.True(t, ok)
 	require.Equal(t, map[string]net.IP{"record1": net.ParseIP("::1")}, rs)
 }
 
 func TestCreateRecordInvalid(t *testing.T) {
 	t.Parallel()
+	mockCtrl := gomock.NewController(t)
 
-	ts, mux, h := newHandle(t)
-	defer ts.Close()
+	mux, h := newHandle(t)
 
 	zh := newZonesHandler(t, mux)
 	zh.set(map[string]int{"test.org": 1}, 2)
 
-	actualID, ok := h.CreateRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, net.ParseIP("::1"), 100, false)
+	mockPP := mocks.NewMockPP(mockCtrl)
+	mockPP.EXPECT().Warningf(pp.EmojiError, "Failed to add a new %s record of %q: %v",
+		"AAAA",
+		"sub.test.org",
+		gomock.Any(),
+	)
+	actualID, ok := h.CreateRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, net.ParseIP("::1"), 100, false) //nolint:lll
 	require.False(t, ok)
 	require.Equal(t, "", actualID)
 }
 
 func TestCreateRecordInvalidZone(t *testing.T) {
 	t.Parallel()
+	mockCtrl := gomock.NewController(t)
 
-	ts, _, h := newHandle(t)
-	defer ts.Close()
+	_, h := newHandle(t)
 
-	actualID, ok := h.CreateRecord(context.Background(), 3, "sub.test.org", ipnet.IP6, net.ParseIP("::1"), 100, false)
+	mockPP := mocks.NewMockPP(mockCtrl)
+	mockPP.EXPECT().Warningf(pp.EmojiError, "Failed to check the existence of a zone named %q: %v",
+		"sub.test.org",
+		gomock.Any(),
+	)
+	actualID, ok := h.CreateRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, net.ParseIP("::1"), 100, false) //nolint:lll
 	require.False(t, ok)
 	require.Equal(t, "", actualID)
 }
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 668d9ab..c6a8518 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -1,11 +1,11 @@
 package config_test
 
 import (
-	"os"
 	"testing"
 	"testing/fstest"
 	"time"
 
+	"github.com/golang/mock/gomock"
 	"github.com/stretchr/testify/require"
 
 	"github.com/favonia/cloudflare-ddns/internal/api"
@@ -13,7 +13,8 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/detector"
 	"github.com/favonia/cloudflare-ddns/internal/file"
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
-	"github.com/favonia/cloudflare-ddns/internal/quiet"
+	"github.com/favonia/cloudflare-ddns/internal/mocks"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
 func TestDefaultConfigNotNil(t *testing.T) {
@@ -22,36 +23,53 @@ func TestDefaultConfigNotNil(t *testing.T) {
 	require.NotNil(t, config.Default())
 }
 
-//nolint: paralleltest // environment vars are global
+//nolint:paralleltest // environment vars are global
 func TestReadAuthToken(t *testing.T) {
 	unset("CF_API_TOKEN")
 	unset("CF_API_TOKEN_FILE")
 	unset("CF_ACCOUNT_ID")
 
 	for name, tc := range map[string]struct {
-		token   string
-		account string
-		ok      bool
+		token         string
+		account       string
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
 	}{
-		"full":      {"123456789", "secret account", true},
-		"noaccount": {"123456789", "", true},
-		"notoken":   {"", "account", false},
-		"copycat":   {"YOUR-CLOUDFLARE-API-TOKEN", "", false},
+		"full":      {"123456789", "secret account", true, nil},
+		"noaccount": {"123456789", "", true, nil},
+		"notoken": {
+			"", "account", false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "Needs either CF_API_TOKEN or CF_API_TOKEN_FILE")
+			},
+		},
+		"copycat": {
+			"YOUR-CLOUDFLARE-API-TOKEN", "", false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "You need to provide a real API token as CF_API_TOKEN")
+			},
+		},
 	} {
 		tc := tc
 		t.Run(name, func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+
 			set("CF_API_TOKEN", tc.token)
 			set("CF_ACCOUNT_ID", tc.account)
 			defer unset("CF_API_TOKEN")
 			defer unset("CF_ACCOUNT_ID")
 
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+
 			var field api.Auth
-			ok := config.ReadAuth(quiet.QUIET, 2, &field)
+			ok := config.ReadAuth(mockPP, &field)
+			require.Equal(t, tc.ok, ok)
 			if tc.ok {
-				require.True(t, ok)
 				require.Equal(t, &api.CloudflareAuth{Token: tc.token, AccountID: tc.account, BaseURL: ""}, field)
 			} else {
-				require.False(t, ok)
 				require.Nil(t, field)
 			}
 		})
@@ -62,11 +80,7 @@ func useMemFS(memfs fstest.MapFS) {
 	file.FS = memfs
 }
 
-func useDirFS() {
-	file.FS = os.DirFS("/")
-}
-
-//nolint: paralleltest // environment vars and file system are global
+//nolint:funlen,paralleltest // environment vars and file system are global
 func TestReadAuthTokenWithFile(t *testing.T) {
 	unset("CF_API_TOKEN")
 	unset("CF_API_TOKEN_FILE")
@@ -79,15 +93,39 @@ func TestReadAuthTokenWithFile(t *testing.T) {
 		actualPath    string
 		actualContent string
 		expected      string
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
 	}{
-		"ok":           {"", "test.txt", "secret account", "test.txt", "hello", "hello"},
-		"both":         {"123456789", "test.txt", "secret account", "test.txt", "hello", ""},
-		"wrong.path":   {"123456789", "test.txt", "secret account", "wrong.txt", "hello", ""},
-		"empty":        {"", "test.txt", "secret account", "test.txt", "", ""},
-		"invalid path": {"", "dir/test.txt", "secret account", "dir", "hello", ""},
+		"ok": {"", "test.txt", "secret account", "test.txt", "hello", "hello", true, nil},
+		"both": {
+			"123456789", "test.txt", "secret account", "test.txt", "hello", "", false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "Cannot have both CF_API_TOKEN and CF_API_TOKEN_FILE set")
+			},
+		},
+		"wrong.path": {
+			"", "wrong.txt", "secret account", "actual.txt", "hello", "", false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "Failed to read %q: %v", "wrong.txt", gomock.Any())
+			},
+		},
+		"empty": {
+			"", "test.txt", "secret account", "test.txt", "", "", false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "The token in the file specified by CF_API_TOKEN_FILE is empty")
+			},
+		},
+		"invalid path": {
+			"", "dir", "secret account", "dir/test.txt", "hello", "", false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "Failed to read %q: %v", "dir", gomock.Any())
+			},
+		},
 	} {
 		tc := tc
 		t.Run(name, func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+
 			set("CF_API_TOKEN", tc.token)
 			set("CF_API_TOKEN_FILE", tc.tokenFile)
 			set("CF_ACCOUNT_ID", tc.account)
@@ -103,33 +141,36 @@ func TestReadAuthTokenWithFile(t *testing.T) {
 					Sys:     nil,
 				},
 			})
-			defer useDirFS()
 
 			var field api.Auth
-			ok := config.ReadAuth(quiet.QUIET, 2, &field)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			ok := config.ReadAuth(mockPP, &field)
+			require.Equal(t, tc.ok, ok)
 			if tc.expected != "" {
-				require.True(t, ok)
 				require.Equal(t, &api.CloudflareAuth{Token: tc.expected, AccountID: tc.account, BaseURL: ""}, field)
 			} else {
-				require.False(t, ok)
 				require.Nil(t, field)
 			}
 		})
 	}
 }
 
-//nolint: paralleltest // environment vars are global
+//nolint:paralleltest // environment vars are global
 func TestReadDomainMap(t *testing.T) {
 	unset("DOMAINS")
 	unset("IP4_DOMAINS")
 	unset("IP6_DOMAINS")
 
 	for name, tc := range map[string]struct {
-		domains    string
-		ip4Domains string
-		ip6Domains string
-		expected   map[ipnet.Type][]api.FQDN
-		ok         bool
+		domains       string
+		ip4Domains    string
+		ip6Domains    string
+		expected      map[ipnet.Type][]api.FQDN
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
 	}{
 		"full": {
 			"  a1, a2", "b1,  b2,b2", "c1,c2",
@@ -138,6 +179,7 @@ func TestReadDomainMap(t *testing.T) {
 				ipnet.IP6: {"a1", "a2", "c1", "c2"},
 			},
 			true,
+			nil,
 		},
 		"empty": {
 			" ", "   ", "",
@@ -146,10 +188,13 @@ func TestReadDomainMap(t *testing.T) {
 				ipnet.IP6: {},
 			},
 			true,
+			nil,
 		},
 	} {
 		tc := tc
 		t.Run(name, func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+
 			set("DOMAINS", tc.domains)
 			set("IP4_DOMAINS", tc.ip4Domains)
 			set("IP6_DOMAINS", tc.ip6Domains)
@@ -158,58 +203,108 @@ func TestReadDomainMap(t *testing.T) {
 			defer unset("IP6_DOMAINS")
 
 			field := map[ipnet.Type][]api.FQDN{}
-			ok := config.ReadDomainMap(quiet.QUIET, 2, field)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			ok := config.ReadDomainMap(mockPP, field)
 			require.Equal(t, tc.ok, ok)
 			require.Equal(t, tc.expected, field)
 		})
 	}
 }
 
-//nolint: paralleltest // environment vars are global
+//nolint:funlen,paralleltest // environment vars are global
 func TestReadPolicyMap(t *testing.T) {
 	unset("IP4_POLICY")
 	unset("IP6_POLICY")
 
+	var (
+		cloudflare = detector.NewCloudflare()
+		local      = detector.NewLocal()
+		unmanaged  = detector.NewUnmanaged()
+		ipify      = detector.NewIpify()
+	)
+
 	for name, tc := range map[string]struct {
-		ip4Policy string
-		ip6Policy string
-		expected  map[ipnet.Type]detector.Policy
-		ok        bool
+		ip4Policy     string
+		ip6Policy     string
+		expected      map[ipnet.Type]detector.Policy
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
 	}{
 		"full": {
 			"cloudflare", "ipify",
 			map[ipnet.Type]detector.Policy{
-				ipnet.IP4: detector.NewCloudflare(),
-				ipnet.IP6: detector.NewIpify(),
+				ipnet.IP4: cloudflare,
+				ipnet.IP6: ipify,
+			},
+			true,
+			nil,
+		},
+		"4": {
+			"local", "  ",
+			map[ipnet.Type]detector.Policy{
+				ipnet.IP4: local,
+				ipnet.IP6: local,
 			},
 			true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "IP6_POLICY", "local")
+			},
+		},
+		"6": {
+			"    ", "ipify",
+			map[ipnet.Type]detector.Policy{
+				ipnet.IP4: unmanaged,
+				ipnet.IP6: ipify,
+			},
+			true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "IP4_POLICY", "unmanaged")
+			},
 		},
 		"empty": {
 			" ", "   ",
 			map[ipnet.Type]detector.Policy{
-				ipnet.IP4: nil,
-				ipnet.IP6: nil,
+				ipnet.IP4: unmanaged,
+				ipnet.IP6: local,
 			},
 			true,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "IP4_POLICY", "unmanaged"),
+					m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "IP6_POLICY", "local"),
+				)
+			},
 		},
 		"illformed": {
 			" flare", "   ",
 			map[ipnet.Type]detector.Policy{
-				ipnet.IP4: nil,
-				ipnet.IP6: nil,
+				ipnet.IP4: unmanaged,
+				ipnet.IP6: local,
 			},
 			false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: not a valid policy", "flare")
+			},
 		},
 	} {
 		tc := tc
 		t.Run(name, func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+
 			set("IP4_POLICY", tc.ip4Policy)
 			set("IP6_POLICY", tc.ip6Policy)
 			defer unset("IP4_POLICY")
 			defer unset("IP6_POLICY")
 
-			field := map[ipnet.Type]detector.Policy{ipnet.IP4: nil, ipnet.IP6: nil}
-			ok := config.ReadPolicyMap(quiet.QUIET, 2, field)
+			field := map[ipnet.Type]detector.Policy{ipnet.IP4: unmanaged, ipnet.IP6: local}
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			ok := config.ReadPolicyMap(mockPP, field)
 			require.Equal(t, tc.ok, ok)
 			require.Equal(t, tc.expected, field)
 		})
diff --git a/internal/config/env_test.go b/internal/config/env_test.go
index df965e3..b6f0f90 100644
--- a/internal/config/env_test.go
+++ b/internal/config/env_test.go
@@ -6,14 +6,15 @@ import (
 	"testing"
 	"time"
 
+	"github.com/golang/mock/gomock"
 	"github.com/stretchr/testify/require"
 
 	"github.com/favonia/cloudflare-ddns/internal/api"
 	"github.com/favonia/cloudflare-ddns/internal/config"
 	"github.com/favonia/cloudflare-ddns/internal/cron"
 	"github.com/favonia/cloudflare-ddns/internal/detector"
+	"github.com/favonia/cloudflare-ddns/internal/mocks"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
-	"github.com/favonia/cloudflare-ddns/internal/quiet"
 )
 
 const keyPrefix = "TEST-11D39F6A9A97AFAFD87CCEB-"
@@ -30,7 +31,7 @@ func unset(key string) {
 	os.Unsetenv(key)
 }
 
-//nolint: paralleltest // environment vars are global
+//nolint:paralleltest // environment vars are global
 func TestGetenv(t *testing.T) {
 	key := keyPrefix + "VAR"
 	for name, tc := range map[string]struct {
@@ -55,200 +56,245 @@ func TestGetenv(t *testing.T) {
 	}
 }
 
-//nolint: paralleltest // environment vars are global
+//nolint:paralleltest // environment vars are global
 func TestReadQuiet(t *testing.T) {
 	key := keyPrefix + "QUIET"
 	for name, tc := range map[string]struct {
-		set      bool
-		val      string
-		oldField quiet.Quiet
-		newField quiet.Quiet
-		ok       bool
+		set           bool
+		val           string
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
 	}{
-		"nil1":     {false, "", quiet.VERBOSE, quiet.VERBOSE, true},
-		"nil2":     {false, "", quiet.QUIET, quiet.QUIET, true},
-		"empty1":   {true, "  ", quiet.VERBOSE, quiet.VERBOSE, true},
-		"empty2":   {true, " ", quiet.QUIET, quiet.QUIET, true},
-		"true1":    {true, "true   ", quiet.VERBOSE, quiet.QUIET, true},
-		"true2":    {true, " true", quiet.QUIET, quiet.QUIET, true},
-		"false1":   {true, "    false ", quiet.VERBOSE, quiet.VERBOSE, true},
-		"false2":   {true, " false    ", quiet.QUIET, quiet.VERBOSE, true},
-		"illform1": {true, "weird", quiet.VERBOSE, quiet.VERBOSE, false},
-		"illform2": {true, "weird", quiet.QUIET, quiet.QUIET, false},
+		"nil":   {false, "", true, nil},
+		"empty": {true, " ", true, nil},
+		"true": {
+			true, " true", true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().SetLevel(pp.Notice)
+			},
+		},
+		"false": {
+			true, "    false ", true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().SetLevel(pp.Info)
+			},
+		},
+		"illform": {
+			true, "weird", false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: %v", "weird", gomock.Any())
+			},
+		},
 	} {
 		tc := tc
 		t.Run(name, func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+
 			if tc.set {
 				set(key, tc.val)
 				defer unset(key)
 			}
 
-			field := tc.oldField
-			ok := config.ReadQuiet(pp.Indent(1), key, &field)
-			require.Equal(t, tc.ok, ok)
-			require.Equal(t, tc.newField, field)
-		})
-	}
-}
-
-//nolint: paralleltest // environment vars are global
-func TestReadString(t *testing.T) {
-	key := keyPrefix + "STRING"
-	for name, tc := range map[string]struct {
-		set      bool
-		val      string
-		quiet    quiet.Quiet
-		oldField string
-		newField string
-		ok       bool
-	}{
-		"nil1":    {false, "", quiet.VERBOSE, "original", "original", true},
-		"nil2":    {false, "", quiet.QUIET, "original", "original", true},
-		"empty1":  {true, "  ", quiet.VERBOSE, "original", "original", true},
-		"empty2":  {true, " ", quiet.QUIET, "original", "original", true},
-		"random1": {true, " ran dom ", quiet.VERBOSE, "original", "ran dom", true},
-		"random2": {true, "  random", quiet.QUIET, "original", "random", true},
-	} {
-		tc := tc
-		t.Run(name, func(t *testing.T) {
-			if tc.set {
-				set(key, tc.val)
-				defer unset(key)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
 			}
 
-			field := tc.oldField
-			ok := config.ReadString(tc.quiet, pp.Indent(1), key, &field)
+			var wrappedPP pp.PP = mockPP
+
+			ok := config.ReadQuiet(key, &wrappedPP)
 			require.Equal(t, tc.ok, ok)
-			require.Equal(t, tc.newField, field)
 		})
 	}
 }
 
-//nolint: paralleltest // environment vars are global
+//nolint:funlen,paralleltest // environment vars are global
 func TestReadBool(t *testing.T) {
 	key := keyPrefix + "BOOL"
 	for name, tc := range map[string]struct {
-		set      bool
-		val      string
-		quiet    quiet.Quiet
-		oldField bool
-		newField bool
-		ok       bool
+		set           bool
+		val           string
+		oldField      bool
+		newField      bool
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
 	}{
-		"nil1":     {false, "", quiet.VERBOSE, true, true, true},
-		"nil2":     {false, "", quiet.QUIET, false, false, true},
-		"empty1":   {true, " ", quiet.VERBOSE, true, true, true},
-		"empty2":   {true, " \t ", quiet.QUIET, false, false, true},
-		"true1":    {true, "true ", quiet.VERBOSE, true, true, true},
-		"true2":    {true, " \t true", quiet.QUIET, false, true, true},
-		"false1":   {true, "false ", quiet.VERBOSE, true, false, true},
-		"false2":   {true, " false", quiet.QUIET, false, false, true},
-		"illform1": {true, "weird\t  ", quiet.VERBOSE, false, false, false},
-		"illform2": {true, " weird", quiet.QUIET, true, true, false},
+		"nil1": {
+			false, "", true, true, true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%t", "TEST-11D39F6A9A97AFAFD87CCEB-BOOL", true)
+			},
+		},
+		"nil2": {
+			false, "", false, false, true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%t", "TEST-11D39F6A9A97AFAFD87CCEB-BOOL", false)
+			},
+		},
+		"empty1": {
+			true, " ", true, true, true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%t", "TEST-11D39F6A9A97AFAFD87CCEB-BOOL", true)
+			},
+		},
+		"empty2": {
+			true, " \t ", false, false, true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%t", "TEST-11D39F6A9A97AFAFD87CCEB-BOOL", false)
+			},
+		},
+		"true1":  {true, "true ", true, true, true, nil},
+		"true2":  {true, " \t true", false, true, true, nil},
+		"false1": {true, "false ", true, false, true, nil},
+		"false2": {true, " false", false, false, true, nil},
+		"illform1": {
+			true, "weird\t  ", false, false, false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: %v", "weird", gomock.Any())
+			},
+		},
+		"illform2": {
+			true, " weird", true, true, false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: %v", "weird", gomock.Any())
+			},
+		},
 	} {
 		tc := tc
 		t.Run(name, func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+
 			if tc.set {
 				set(key, tc.val)
 				defer unset(key)
 			}
 
 			field := tc.oldField
-			ok := config.ReadBool(tc.quiet, pp.Indent(1), key, &field)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			ok := config.ReadBool(mockPP, key, &field)
 			require.Equal(t, tc.ok, ok)
 			require.Equal(t, tc.newField, field)
 		})
 	}
 }
 
-//nolint: paralleltest // environment vars are global
+//nolint:funlen,paralleltest // environment vars are global
 func TestReadNonnegInt(t *testing.T) {
 	key := keyPrefix + "INT"
 	for name, tc := range map[string]struct {
-		set      bool
-		val      string
-		quiet    quiet.Quiet
-		oldField int
-		newField int
-		ok       bool
+		set           bool
+		val           string
+		oldField      int
+		newField      int
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
 	}{
-		"nil-quiet":     {false, "", quiet.QUIET, 100, 100, true},
-		"nil-verbose":   {false, "", quiet.VERBOSE, 100, 100, true},
-		"empty-quiet":   {true, "", quiet.QUIET, 100, 100, true},
-		"empty-verbose": {true, "", quiet.VERBOSE, 100, 100, true},
-		"zero":          {true, "0   ", quiet.VERBOSE, 100, 0, true},
-		"-1-quiet":      {true, "   -1", quiet.QUIET, 100, 100, false},
-		"-1-verbose":    {true, "   -1", quiet.VERBOSE, 100, 100, false},
-		"1":             {true, "   1   ", quiet.VERBOSE, 100, 1, true},
-		"1.0":           {true, "   1.0   ", quiet.VERBOSE, 100, 100, false},
-		"words-quiet":   {true, "   words   ", quiet.QUIET, 100, 100, false},
-		"words-verbose": {true, "   words   ", quiet.VERBOSE, 100, 100, false},
-		"9999999":       {true, "   9999999   ", quiet.VERBOSE, 100, 9999999, true},
+		"nil": {
+			false, "", 100, 100, true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%d", "TEST-11D39F6A9A97AFAFD87CCEB-INT", 100)
+			},
+		},
+		"empty": {
+			true, "", 100, 100, true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%d", "TEST-11D39F6A9A97AFAFD87CCEB-INT", 100)
+			},
+		},
+		"zero": {true, "0   ", 100, 0, true, nil},
+		"-1": {
+			true, "   -1", 100, 100, false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: %d is negative", "-1", gomock.Any())
+			},
+		},
+		"1": {true, "   1   ", 100, 1, true, nil},
+		"1.0": {
+			true, "   1.0   ", 100, 100, false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: %v", "1.0", gomock.Any())
+			},
+		},
+		"words": {
+			true, "   word   ", 100, 100, false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: %v", "word", gomock.Any())
+			},
+		},
+		"9999999": {true, "   9999999   ", 100, 9999999, true, nil},
 	} {
 		tc := tc
 		t.Run(name, func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+
 			if tc.set {
 				set(key, tc.val)
 				defer unset(key)
 			}
 
 			field := tc.oldField
-			ok := config.ReadNonnegInt(tc.quiet, pp.NoIndent, key, &field)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			ok := config.ReadNonnegInt(mockPP, key, &field)
 			require.Equal(t, tc.ok, ok)
 			require.Equal(t, tc.newField, field)
 		})
 	}
 }
 
-//nolint: paralleltest // environment vars are global
+//nolint:paralleltest // environment vars are global
 func TestReadDomains(t *testing.T) {
 	key := keyPrefix + "DOMAINS"
 	type ds = []api.FQDN
 	for name, tc := range map[string]struct {
-		set      bool
-		val      string
-		quiet    quiet.Quiet
-		oldField ds
-		newField ds
-		ok       bool
+		set           bool
+		val           string
+		oldField      ds
+		newField      ds
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
 	}{
-		"nil-quiet":   {false, "", quiet.QUIET, ds{"test.org"}, ds{}, true},
-		"nil-verbose": {false, "", quiet.VERBOSE, ds{"test.org"}, ds{}, true},
-		"empty":       {true, "", quiet.VERBOSE, ds{"test.org"}, ds{}, true},
-		"test1": {
-			true,
-			"書.org ,  Bücher.org  ",
-			quiet.VERBOSE,
+		"nil":   {false, "", ds{"test.org"}, ds{}, true, nil},
+		"empty": {true, "", ds{"test.org"}, ds{}, true, nil},
+		"test1": {true, "書.org ,  Bücher.org  ", ds{"random.org"}, ds{"xn--rov.org", "xn--bcher-kva.org"}, true, nil},
+		"test2": {true, "  \txn--rov.org    ,   xn--Bcher-kva.org  ", ds{"random.org"}, ds{"xn--rov.org", "xn--bcher-kva.org"}, true, nil}, //nolint:lll
+		"illformed": {
+			true, "xn--:D.org",
 			ds{"random.org"},
-			ds{"xn--rov.org", "xn--bcher-kva.org"},
+			ds{"xn--:d.org"},
 			true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(pp.EmojiUserError, "Domain %q was added but it is ill-formed: %v", "xn--:d.org", gomock.Any()) //nolint:lll
+			},
 		},
-		"test2": {
-			true,
-			"  \txn--rov.org    ,   xn--Bcher-kva.org  ",
-			quiet.VERBOSE,
-			ds{"random.org"},
-			ds{"xn--rov.org", "xn--bcher-kva.org"},
-			true,
-		},
-		"illformed": {true, "xn--:D.org", quiet.VERBOSE, ds{"random.org"}, ds{"xn--:d.org"}, true},
 	} {
 		tc := tc
 		t.Run(name, func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+
 			if tc.set {
 				set(key, tc.val)
 				defer unset(key)
 			}
 
 			field := tc.oldField
-			ok := config.ReadDomains(quiet.QUIET, pp.NoIndent, key, &field)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			ok := config.ReadDomains(mockPP, key, &field)
 			require.Equal(t, tc.ok, ok)
 			require.Equal(t, tc.newField, field)
 		})
 	}
 }
 
-//nolint: paralleltest // environment vars are global
+//nolint:paralleltest // environment vars are global
 func TestReadPolicy(t *testing.T) {
 	key := keyPrefix + "POLICY"
 
@@ -260,102 +306,164 @@ func TestReadPolicy(t *testing.T) {
 	)
 
 	for name, tc := range map[string]struct {
-		set      bool
-		val      string
-		quiet    quiet.Quiet
-		oldField detector.Policy
-		newField detector.Policy
-		ok       bool
+		set           bool
+		val           string
+		oldField      detector.Policy
+		newField      detector.Policy
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
 	}{
-		"nil-quiet":     {false, "", quiet.QUIET, unmanaged, unmanaged, true},
-		"nil-verbose":   {false, "", quiet.VERBOSE, local, local, true},
-		"empty-quiet":   {true, "", quiet.QUIET, unmanaged, unmanaged, true},
-		"empty-verbose": {true, "", quiet.VERBOSE, unmanaged, unmanaged, true},
-		"cloudflare":    {true, "    cloudflare\t   ", quiet.VERBOSE, unmanaged, cloudflare, true},
-		"unmanaged":     {true, "   unmanaged   ", quiet.QUIET, cloudflare, unmanaged, true},
-		"local":         {true, "   local   ", quiet.QUIET, cloudflare, local, true},
-		"ipify":         {true, "     ipify  ", quiet.QUIET, cloudflare, ipify, true},
-		"others":        {true, "   something-else ", quiet.VERBOSE, ipify, ipify, false},
+		"nil": {
+			false, "", unmanaged, unmanaged, true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "TEST-11D39F6A9A97AFAFD87CCEB-POLICY", "unmanaged")
+			},
+		},
+		"empty": {
+			true, "", local, local, true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "TEST-11D39F6A9A97AFAFD87CCEB-POLICY", "local")
+			},
+		},
+		"cloudflare": {true, "    cloudflare\t   ", unmanaged, cloudflare, true, nil},
+		"unmanaged":  {true, "   unmanaged   ", cloudflare, unmanaged, true, nil},
+		"local":      {true, "   local   ", cloudflare, local, true, nil},
+		"ipify":      {true, "     ipify  ", cloudflare, ipify, true, nil},
+		"others": {
+			true, "   something-else ", ipify, ipify, false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: not a valid policy", "something-else")
+			},
+		},
 	} {
 		tc := tc
 		t.Run(name, func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+
 			if tc.set {
 				set(key, tc.val)
 				defer unset(key)
 			}
 
 			field := tc.oldField
-			ok := config.ReadPolicy(tc.quiet, pp.NoIndent, key, &field)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			ok := config.ReadPolicy(mockPP, key, &field)
 			require.Equal(t, tc.ok, ok)
 			require.Equal(t, tc.newField, field)
 		})
 	}
 }
 
-//nolint: paralleltest // environment vars are global
+//nolint:paralleltest // environment vars are global
 func TestReadNonnegDuration(t *testing.T) {
 	key := keyPrefix + "DURATION"
 
 	for name, tc := range map[string]struct {
-		set      bool
-		val      string
-		quiet    quiet.Quiet
-		oldField time.Duration
-		newField time.Duration
-		ok       bool
+		set           bool
+		val           string
+		oldField      time.Duration
+		newField      time.Duration
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
 	}{
-		"nil-quiet":     {false, "", quiet.QUIET, time.Second, time.Second, true},
-		"nil-verbose":   {false, "", quiet.VERBOSE, 0, 0, true},
-		"empty-quiet":   {true, "", quiet.QUIET, time.Hour, time.Hour, true},
-		"empty-verbose": {true, "", quiet.VERBOSE, 200, 200, true},
-		"100s":          {true, "    100s\t   ", quiet.VERBOSE, 0, time.Second * 100, true},
-		"1":             {true, "  1  ", quiet.QUIET, 123, 123, false},
-		"-1s":           {true, "  -1s  ", quiet.QUIET, 456, 456, false},
-		"0h":            {true, "  0h  ", quiet.QUIET, 123456, 0, true},
+		"nil": {
+			false, "", time.Second, time.Second, true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%v", "TEST-11D39F6A9A97AFAFD87CCEB-DURATION", time.Second)
+			},
+		},
+		"empty": {
+			true, "", 0, 0, true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%v", "TEST-11D39F6A9A97AFAFD87CCEB-DURATION", time.Duration(0))
+			},
+		},
+		"100s": {true, "    100s\t   ", 0, time.Second * 100, true, nil},
+		"1": {
+			true, "  1  ", 123, 123, false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: %v", "1", gomock.Any())
+			},
+		},
+		"-1s": {
+			true, "  -1s  ", 456, 456, false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: %v is negative", "-1s", -time.Second)
+			},
+		},
+		"0h": {true, "  0h  ", 123456, 0, true, nil},
 	} {
 		tc := tc
 		t.Run(name, func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+
 			if tc.set {
 				set(key, tc.val)
 				defer unset(key)
 			}
 
 			field := tc.oldField
-			ok := config.ReadNonnegDuration(tc.quiet, pp.NoIndent, key, &field)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			ok := config.ReadNonnegDuration(mockPP, key, &field)
 			require.Equal(t, tc.ok, ok)
 			require.Equal(t, tc.newField, field)
 		})
 	}
 }
 
-//nolint: paralleltest // environment vars are global
+//nolint:paralleltest // environment vars are global
 func TestReadCron(t *testing.T) {
 	key := keyPrefix + "CRON"
 
 	for name, tc := range map[string]struct {
-		set      bool
-		val      string
-		quiet    quiet.Quiet
-		oldField cron.Schedule
-		newField cron.Schedule
-		ok       bool
+		set           bool
+		val           string
+		oldField      cron.Schedule
+		newField      cron.Schedule
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
 	}{
-		"nil-quiet":     {false, "", quiet.QUIET, cron.MustNew("@every 1h"), cron.MustNew("@every 1h"), true},
-		"nil-verbose":   {false, "", quiet.VERBOSE, cron.MustNew("* * * * *"), cron.MustNew("* * * * *"), true},
-		"empty-quiet":   {true, "", quiet.QUIET, cron.MustNew("@every 3m"), cron.MustNew("@every 3m"), true},
-		"empty-verbose": {true, "", quiet.VERBOSE, cron.MustNew("@yearly"), cron.MustNew("@yearly"), true},
-		"daily":         {true, " @daily  ", quiet.VERBOSE, cron.MustNew("@yearly"), cron.MustNew("@daily"), true},
-		"illformed":     {true, " @ddddd  ", quiet.VERBOSE, cron.MustNew("*/4 * * * *"), cron.MustNew("*/4 * * * *"), false},
+		"nil": {
+			false, "", cron.MustNew("* * * * *"), cron.MustNew("* * * * *"), true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "TEST-11D39F6A9A97AFAFD87CCEB-CRON", "* * * * *")
+			},
+		},
+		"empty": {
+			true, "", cron.MustNew("@every 3m"), cron.MustNew("@every 3m"), true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "TEST-11D39F6A9A97AFAFD87CCEB-CRON", "@every 3m")
+			},
+		},
+		"@": {true, " @daily  ", cron.MustNew("@yearly"), cron.MustNew("@daily"), true, nil},
+		"illformed": {
+			true, " @ddddd  ", cron.MustNew("*/4 * * * *"), cron.MustNew("*/4 * * * *"), false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: %v", "@ddddd", gomock.Any())
+			},
+		},
 	} {
 		tc := tc
 		t.Run(name, func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+
 			if tc.set {
 				set(key, tc.val)
 				defer unset(key)
 			}
 
 			field := tc.oldField
-			ok := config.ReadCron(tc.quiet, pp.NoIndent, key, &field)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			ok := config.ReadCron(mockPP, key, &field)
 			require.Equal(t, tc.ok, ok)
 			require.Equal(t, tc.newField, field)
 		})
diff --git a/internal/detector/dns_test.go b/internal/detector/dns_test.go
index 8e713a0..6187800 100644
--- a/internal/detector/dns_test.go
+++ b/internal/detector/dns_test.go
@@ -8,12 +8,15 @@ import (
 	"net/http/httptest"
 	"testing"
 
+	"github.com/golang/mock/gomock"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"golang.org/x/net/dns/dnsmessage"
 
 	"github.com/favonia/cloudflare-ddns/internal/detector"
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/mocks"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
 func TestDNSOverHTTPSIsManaged(t *testing.T) {
@@ -97,15 +100,16 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 	ip6 := net.ParseIP("::1:2:3:4").To16()
 
 	for name, tc := range map[string]struct {
-		urlKey   ipnet.Type
-		ipNet    ipnet.Type
-		name     string
-		class    dnsmessage.Class
-		response bool
-		header   *dnsmessage.Header
-		idShift  uint16
-		answers  []dnsmessage.Resource
-		expected net.IP
+		urlKey        ipnet.Type
+		ipNet         ipnet.Type
+		name          string
+		class         dnsmessage.Class
+		response      bool
+		header        *dnsmessage.Header
+		idShift       uint16
+		answers       []dnsmessage.Resource
+		expected      net.IP
+		prepareMockPP func(*mocks.MockPP)
 	}{
 		"correct": {
 			ipnet.IP4, ipnet.IP4, "test.",
@@ -125,6 +129,7 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 				},
 			},
 			ip4,
+			nil,
 		},
 		"illformed": {
 			ipnet.IP4, ipnet.IP4, "test",
@@ -144,6 +149,13 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 				},
 			},
 			nil,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(
+					pp.EmojiError,
+					"Failed to prepare the DNS query: %v",
+					gomock.Any(),
+				)
+			},
 		},
 		"6to4": {
 			ipnet.IP4, ipnet.IP4, "test.",
@@ -163,6 +175,14 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 				},
 			},
 			nil,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(
+					pp.EmojiError,
+					"%q is not a valid %s address",
+					ip6,
+					"IPv4",
+				)
+			},
 		},
 		"unmatched-id": {
 			ipnet.IP4, ipnet.IP4, "test.",
@@ -182,6 +202,11 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 				},
 			},
 			nil,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(
+					pp.EmojiImpossible, `Invalid DNS response: mismatched transaction ID`,
+				)
+			},
 		},
 		"notxt": {
 			ipnet.IP4, ipnet.IP4, "test.",
@@ -191,6 +216,11 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 			0,
 			[]dnsmessage.Resource{},
 			nil,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(
+					pp.EmojiImpossible, `Invalid DNS response: no TXT records or all TXT records are empty`,
+				)
+			},
 		},
 		"notresponse": {
 			ipnet.IP4, ipnet.IP4, "test.",
@@ -210,6 +240,11 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 				},
 			},
 			nil,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(
+					pp.EmojiImpossible, `Invalid DNS response: QR was not set`,
+				)
+			},
 		},
 		"truncated": {
 			ipnet.IP4, ipnet.IP4, "test.",
@@ -229,6 +264,11 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 				},
 			},
 			nil,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(
+					pp.EmojiImpossible, `Invalid DNS response: TC was set`,
+				)
+			},
 		},
 		"rcode": {
 			ipnet.IP4, ipnet.IP4, "test.",
@@ -248,6 +288,13 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 				},
 			},
 			nil,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(
+					pp.EmojiImpossible,
+					"Invalid DNS response: response code is %v",
+					dnsmessage.RCodeFormatError,
+				)
+			},
 		},
 		"irrelevant-records1": {
 			ipnet.IP4, ipnet.IP4, "test.",
@@ -276,6 +323,7 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 				},
 			},
 			ip4,
+			nil,
 		},
 		"irrelevant-records2": {
 			ipnet.IP4, ipnet.IP4, "test.",
@@ -304,6 +352,7 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 				},
 			},
 			ip4,
+			nil,
 		},
 		"irrelevant-records3": {
 			ipnet.IP4, ipnet.IP4, "test.",
@@ -323,6 +372,11 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 				},
 			},
 			nil,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(
+					pp.EmojiImpossible, `Invalid DNS response: no TXT records or all TXT records are empty`,
+				)
+			},
 		},
 		"irrelevant-records4": {
 			ipnet.IP4, ipnet.IP4, "test.",
@@ -351,6 +405,7 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 				},
 			},
 			ip4,
+			nil,
 		},
 		"empty-strings-and-padding": {
 			ipnet.IP4, ipnet.IP4, "test.",
@@ -379,6 +434,7 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 				},
 			},
 			ip4,
+			nil,
 		},
 		"multiple1": {
 			ipnet.IP4, ipnet.IP4, "test.",
@@ -398,6 +454,11 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 				},
 			},
 			nil,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(
+					pp.EmojiImpossible, `Invalid DNS response: more than one string in TXT records`,
+				)
+			},
 		},
 		"multiple2": {
 			ipnet.IP4, ipnet.IP4, "test.",
@@ -426,6 +487,11 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 				},
 			},
 			nil,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(
+					pp.EmojiImpossible, `Invalid DNS response: more than one string in TXT records`,
+				)
+			},
 		},
 		"noresponse": {
 			ipnet.IP4, ipnet.IP4, "test.",
@@ -445,6 +511,13 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 				},
 			},
 			nil,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(
+					pp.EmojiImpossible,
+					"Invalid DNS response: %v",
+					gomock.Any(),
+				)
+			},
 		},
 		"nourl": {
 			ipnet.IP4, ipnet.IP6, "test.",
@@ -464,11 +537,19 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 				},
 			},
 			nil,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(
+					pp.EmojiImpossible,
+					"Unhandled IP network: %s",
+					"IPv6",
+				)
+			},
 		},
 	} {
 		tc := tc
 		t.Run(name, func(t *testing.T) {
 			t.Parallel()
+			mockCtrl := gomock.NewController(t)
 
 			server := setupServer(t, tc.name, tc.class, tc.response, tc.header, tc.idShift, tc.answers)
 
@@ -483,7 +564,11 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 				},
 			}
 
-			ip := policy.GetIP(context.Background(), 3, tc.ipNet)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			ip := policy.GetIP(context.Background(), mockPP, tc.ipNet)
 			require.Equal(t, tc.expected, ip)
 		})
 	}
diff --git a/internal/detector/http_test.go b/internal/detector/http_test.go
index feb5618..6c49036 100644
--- a/internal/detector/http_test.go
+++ b/internal/detector/http_test.go
@@ -8,10 +8,13 @@ import (
 	"net/http/httptest"
 	"testing"
 
+	"github.com/golang/mock/gomock"
 	"github.com/stretchr/testify/require"
 
 	"github.com/favonia/cloudflare-ddns/internal/detector"
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/mocks"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
 func TestHTTPIsManaged(t *testing.T) {
@@ -36,6 +39,7 @@ func TestHTTPString(t *testing.T) {
 	require.Equal(t, "very secret name", policy.String())
 }
 
+//nolint:funlen
 func TestHTTPGetIP(t *testing.T) {
 	ip4 := net.ParseIP("1.2.3.4").To4()
 	ip6 := net.ParseIP("::1:2:3:4:5:6").To16()
@@ -55,25 +59,66 @@ func TestHTTPGetIP(t *testing.T) {
 
 	t.Run("group", func(t *testing.T) {
 		for name, tc := range map[string]struct {
-			urlKey   ipnet.Type
-			url      string
-			ipNet    ipnet.Type
-			expected net.IP
+			urlKey        ipnet.Type
+			url           string
+			ipNet         ipnet.Type
+			expected      net.IP
+			prepareMockPP func(*mocks.MockPP)
 		}{
-			"4":      {ipnet.IP4, ip4Server.URL, ipnet.IP4, ip4},
-			"6":      {ipnet.IP6, ip6Server.URL, ipnet.IP6, ip6},
-			"4to6":   {ipnet.IP6, ip4Server.URL, ipnet.IP6, ip4.To16()},
-			"6to4":   {ipnet.IP4, ip6Server.URL, ipnet.IP4, nil},
-			"4-nil1": {ipnet.IP4, dummy.URL, ipnet.IP4, nil},
-			"6-nil1": {ipnet.IP6, dummy.URL, ipnet.IP6, nil},
-			"4-nil2": {ipnet.IP4, "", ipnet.IP4, nil},
-			"6-nil2": {ipnet.IP6, "", ipnet.IP6, nil},
-			"4-nil3": {ipnet.IP4, ip4Server.URL, ipnet.IP6, nil},
-			"6-nil3": {ipnet.IP6, ip6Server.URL, ipnet.IP4, nil},
+			"4":    {ipnet.IP4, ip4Server.URL, ipnet.IP4, ip4, nil},
+			"6":    {ipnet.IP6, ip6Server.URL, ipnet.IP6, ip6, nil},
+			"4to6": {ipnet.IP6, ip4Server.URL, ipnet.IP6, ip4.To16(), nil},
+			"6to4": {
+				ipnet.IP4, ip6Server.URL, ipnet.IP4, nil,
+				func(m *mocks.MockPP) {
+					m.EXPECT().Warningf(
+						pp.EmojiError, "%q is not a valid %s address",
+						ip6,
+						"IPv4",
+					)
+				},
+			},
+			"4-nil1": {ipnet.IP4, dummy.URL, ipnet.IP4, nil, nil},
+			"6-nil1": {ipnet.IP6, dummy.URL, ipnet.IP6, nil, nil},
+			"4-nil2": {
+				ipnet.IP4, "", ipnet.IP4, nil,
+				func(m *mocks.MockPP) {
+					m.EXPECT().Warningf(
+						pp.EmojiError,
+						"Failed to send HTTP(S) request to %q: %v",
+						"",
+						gomock.Any(),
+					)
+				},
+			},
+			"6-nil2": {
+				ipnet.IP6, "", ipnet.IP6, nil,
+				func(m *mocks.MockPP) {
+					m.EXPECT().Warningf(
+						pp.EmojiError,
+						"Failed to send HTTP(S) request to %q: %v",
+						"",
+						gomock.Any(),
+					)
+				},
+			},
+			"4-nil3": {
+				ipnet.IP4, ip4Server.URL, ipnet.IP6, nil,
+				func(m *mocks.MockPP) {
+					m.EXPECT().Warningf(pp.EmojiImpossible, "Unhandled IP network: %s", "IPv6")
+				},
+			},
+			"6-nil3": {
+				ipnet.IP6, ip6Server.URL, ipnet.IP4, nil,
+				func(m *mocks.MockPP) {
+					m.EXPECT().Warningf(pp.EmojiImpossible, "Unhandled IP network: %s", "IPv4")
+				},
+			},
 		} {
 			tc := tc
 			t.Run(name, func(t *testing.T) {
 				t.Parallel()
+				mockCtrl := gomock.NewController(t)
 
 				policy := &detector.HTTP{
 					PolicyName: "",
@@ -82,7 +127,11 @@ func TestHTTPGetIP(t *testing.T) {
 					},
 				}
 
-				ip := policy.GetIP(context.Background(), 3, tc.ipNet)
+				mockPP := mocks.NewMockPP(mockCtrl)
+				if tc.prepareMockPP != nil {
+					tc.prepareMockPP(mockPP)
+				}
+				ip := policy.GetIP(context.Background(), mockPP, tc.ipNet)
 				require.Equal(t, tc.expected, ip)
 			})
 		}
diff --git a/internal/detector/local_test.go b/internal/detector/local_test.go
index f63d195..5c0da69 100644
--- a/internal/detector/local_test.go
+++ b/internal/detector/local_test.go
@@ -5,10 +5,13 @@ import (
 	"net"
 	"testing"
 
+	"github.com/golang/mock/gomock"
 	"github.com/stretchr/testify/require"
 
 	"github.com/favonia/cloudflare-ddns/internal/detector"
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/mocks"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
 func TestLocalIsManaged(t *testing.T) {
@@ -40,21 +43,43 @@ func TestLocalGetIP(t *testing.T) {
 	ip6Loopback := net.ParseIP("::1").To16()
 
 	for name, tc := range map[string]struct {
-		addrKey  ipnet.Type
-		addr     string
-		ipNet    ipnet.Type
-		expected net.IP
+		addrKey       ipnet.Type
+		addr          string
+		ipNet         ipnet.Type
+		expected      net.IP
+		prepareMockPP func(*mocks.MockPP)
 	}{
-		"4":      {ipnet.IP4, "127.0.0.1:80", ipnet.IP4, ip4Loopback},
-		"6":      {ipnet.IP6, "[::1]:80", ipnet.IP6, ip6Loopback},
-		"4-nil1": {ipnet.IP4, "", ipnet.IP4, nil},
-		"6-nil1": {ipnet.IP6, "", ipnet.IP6, nil},
-		"4-nil2": {ipnet.IP4, "127.0.0.1:80", ipnet.IP6, nil},
-		"6-nil2": {ipnet.IP6, "::1:80", ipnet.IP4, nil},
+		"4": {ipnet.IP4, "127.0.0.1:80", ipnet.IP4, ip4Loopback, nil},
+		"6": {ipnet.IP6, "[::1]:80", ipnet.IP6, ip6Loopback, nil},
+		"4-nil1": {
+			ipnet.IP4, "", ipnet.IP4, nil,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(pp.EmojiError, "Failed to detect a local %s address: %v", "IPv4", gomock.Any())
+			},
+		},
+		"6-nil1": {
+			ipnet.IP6, "", ipnet.IP6, nil,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(pp.EmojiError, "Failed to detect a local %s address: %v", "IPv6", gomock.Any())
+			},
+		},
+		"4-nil2": {
+			ipnet.IP4, "127.0.0.1:80", ipnet.IP6, nil,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(pp.EmojiImpossible, "Unhandled IP network: %s", "IPv6")
+			},
+		},
+		"6-nil2": {
+			ipnet.IP6, "::1:80", ipnet.IP4, nil,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(pp.EmojiImpossible, "Unhandled IP network: %s", "IPv4")
+			},
+		},
 	} {
 		tc := tc
 		t.Run(name, func(t *testing.T) {
 			t.Parallel()
+			mockCtrl := gomock.NewController(t)
 
 			policy := &detector.Local{
 				PolicyName: "",
@@ -63,7 +88,11 @@ func TestLocalGetIP(t *testing.T) {
 				},
 			}
 
-			ip := policy.GetIP(context.Background(), 3, tc.ipNet)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			ip := policy.GetIP(context.Background(), mockPP, tc.ipNet)
 			require.Equal(t, tc.expected, ip)
 		})
 	}
diff --git a/internal/detector/unmanaged_test.go b/internal/detector/unmanaged_test.go
index d248ddd..7f0ca5d 100644
--- a/internal/detector/unmanaged_test.go
+++ b/internal/detector/unmanaged_test.go
@@ -4,10 +4,12 @@ import (
 	"context"
 	"testing"
 
+	"github.com/golang/mock/gomock"
 	"github.com/stretchr/testify/require"
 
 	"github.com/favonia/cloudflare-ddns/internal/detector"
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/mocks"
 )
 
 func TestUnmanagedIsManaged(t *testing.T) {
@@ -32,8 +34,10 @@ func TestUnmanagedGetIP(t *testing.T) {
 		tc := tc
 		t.Run(name, func(t *testing.T) {
 			t.Parallel()
+			mockCtrl := gomock.NewController(t)
 
-			ip := detector.NewUnmanaged().GetIP(context.Background(), 3, tc)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			ip := detector.NewUnmanaged().GetIP(context.Background(), mockPP, tc)
 			require.Nil(t, ip)
 		})
 	}
diff --git a/internal/file/file_test.go b/internal/file/file_test.go
index 0359d3a..d94b624 100644
--- a/internal/file/file_test.go
+++ b/internal/file/file_test.go
@@ -7,27 +7,29 @@ import (
 	"testing/fstest"
 	"time"
 
+	"github.com/golang/mock/gomock"
 	"github.com/stretchr/testify/require"
 
 	"github.com/favonia/cloudflare-ddns/internal/file"
+	"github.com/favonia/cloudflare-ddns/internal/mocks"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
-func useMemFS(memfs fstest.MapFS) {
+func useMemFS(t *testing.T, memfs fstest.MapFS) {
+	t.Helper()
 	file.FS = memfs
-}
-
-func useDirFS() {
-	file.FS = os.DirFS("/")
+	t.Cleanup(func() { file.FS = os.DirFS("/") })
 }
 
 //nolint:paralleltest // changing global var file.FS
 func TestReadStringOkay(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+
 	path := "test/file.txt"
 	written := " hello world   " // space is intentionally added to test trimming
 	expected := strings.TrimSpace(written)
 
-	useMemFS(fstest.MapFS{
+	useMemFS(t, fstest.MapFS{
 		path: &fstest.MapFile{
 			Data:    []byte(written),
 			Mode:    0o644,
@@ -35,27 +37,30 @@ func TestReadStringOkay(t *testing.T) {
 			Sys:     nil,
 		},
 	})
-	defer useDirFS()
 
-	content, ok := file.ReadString(pp.NoIndent, path)
+	mockPP := mocks.NewMockPP(mockCtrl)
+	content, ok := file.ReadString(mockPP, path)
 	require.True(t, ok)
 	require.Equal(t, expected, content)
 }
 
 //nolint:paralleltest // changing global var file.FS
 func TestReadStringWrongPath(t *testing.T) {
-	useMemFS(fstest.MapFS{})
-	defer useDirFS()
+	mockCtrl := gomock.NewController(t)
+	useMemFS(t, fstest.MapFS{})
 
-	path := "wrong/path.txt"
-	content, ok := file.ReadString(pp.NoIndent, path)
+	path := "/wrong/path.txt"
+	mockPP := mocks.NewMockPP(mockCtrl)
+	mockPP.EXPECT().Errorf(pp.EmojiUserError, "Failed to read %q: %v", path, gomock.Any())
+	content, ok := file.ReadString(mockPP, path)
 	require.False(t, ok)
 	require.Empty(t, content)
 }
 
 //nolint:paralleltest // changing global var file.FS
 func TestReadStringNoAccess(t *testing.T) {
-	useMemFS(fstest.MapFS{
+	mockCtrl := gomock.NewController(t)
+	useMemFS(t, fstest.MapFS{
 		"dir/file.txt": &fstest.MapFile{
 			Data:    []byte("hello"),
 			Mode:    0,
@@ -63,9 +68,10 @@ func TestReadStringNoAccess(t *testing.T) {
 			Sys:     nil,
 		},
 	})
-	defer useDirFS()
 
-	content, ok := file.ReadString(pp.NoIndent, "dir")
+	mockPP := mocks.NewMockPP(mockCtrl)
+	mockPP.EXPECT().Errorf(pp.EmojiUserError, "Failed to read %q: %v", "dir", gomock.Any())
+	content, ok := file.ReadString(mockPP, "dir")
 	require.False(t, ok)
 	require.Empty(t, content)
 }
diff --git a/internal/ipnet/ipnet_test.go b/internal/ipnet/ipnet_test.go
index 699fd6a..5c4adf6 100644
--- a/internal/ipnet/ipnet_test.go
+++ b/internal/ipnet/ipnet_test.go
@@ -9,7 +9,7 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
 )
 
-func TestString(t *testing.T) {
+func TestDescribe(t *testing.T) {
 	t.Parallel()
 	for name, tc := range map[string]struct {
 		input    ipnet.Type
@@ -17,12 +17,12 @@ func TestString(t *testing.T) {
 	}{
 		"4":   {ipnet.IP4, "IPv4"},
 		"6":   {ipnet.IP6, "IPv6"},
-		"100": {ipnet.Type(100), "(unrecognized IP network)"},
+		"100": {ipnet.Type(100), "<unrecognized IP network>"},
 	} {
 		tc := tc
 		t.Run(name, func(t *testing.T) {
 			t.Parallel()
-			require.Equal(t, tc.expected, tc.input.String())
+			require.Equal(t, tc.expected, tc.input.Describe())
 		})
 	}
 }
diff --git a/internal/pp/fmt_test.go b/internal/pp/fmt_test.go
new file mode 100644
index 0000000..0f8d76a
--- /dev/null
+++ b/internal/pp/fmt_test.go
@@ -0,0 +1,85 @@
+package pp_test
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+)
+
+func TestIsEnabledFor(t *testing.T) {
+	t.Parallel()
+
+	for name, tc := range map[string]struct {
+		set      pp.Level
+		test     pp.Level
+		expected bool
+	}{
+		"info-notice": {pp.Info, pp.Notice, true},
+		"erorr-info":  {pp.Error, pp.Info, false},
+	} {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			var buf strings.Builder
+			fmt := pp.New(&buf).SetLevel(tc.set)
+
+			require.Equal(t, tc.expected, fmt.IsEnabledFor(tc.test))
+		})
+	}
+}
+
+func TestIncIndent(t *testing.T) {
+	t.Parallel()
+
+	var buf strings.Builder
+	outer := pp.New(&buf)
+
+	outer.Errorf(pp.EmojiStar, "message1")
+	middle := outer.IncIndent()
+	middle.Errorf(pp.EmojiStar, "message2")
+	inner := middle.IncIndent()
+	outer.Errorf(pp.EmojiStar, "message3")
+	inner.Errorf(pp.EmojiStar, "message4")
+	middle.Errorf(pp.EmojiStar, "message5")
+
+	require.Equal(t, buf.String(),
+		`🌟 message1
+   🌟 message2
+🌟 message3
+      🌟 message4
+   🌟 message5
+`)
+}
+
+func TestPrint(t *testing.T) {
+	t.Parallel()
+
+	for name, tc := range map[string]struct {
+		level    pp.Level
+		expected string
+	}{
+		"info":     {pp.Info, "🌟 info\n🌟 notice\n🌟 warning\n🌟 error\n"},
+		"notice":   {pp.Notice, "🌟 notice\n🌟 warning\n🌟 error\n"},
+		"warning":  {pp.Warning, "🌟 warning\n🌟 error\n"},
+		"errorfmt": {pp.Error, "🌟 error\n"},
+	} {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			var buf strings.Builder
+			fmt := pp.New(&buf).SetLevel(tc.level)
+
+			fmt.Infof(pp.EmojiStar, "info")
+			fmt.Noticef(pp.EmojiStar, "notice")
+			fmt.Warningf(pp.EmojiStar, "warning")
+			fmt.Errorf(pp.EmojiStar, "error")
+
+			require.Equal(t, tc.expected, buf.String())
+		})
+	}
+}
diff --git a/internal/updator/updator_test.go b/internal/updator/updator_test.go
index 2671248..d345f7c 100644
--- a/internal/updator/updator_test.go
+++ b/internal/updator/updator_test.go
@@ -5,100 +5,29 @@ import (
 	"net"
 	"testing"
 
+	"github.com/golang/mock/gomock"
 	"github.com/stretchr/testify/require"
 
 	"github.com/favonia/cloudflare-ddns/internal/api"
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/mocks"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
-	"github.com/favonia/cloudflare-ddns/internal/quiet"
 	"github.com/favonia/cloudflare-ddns/internal/updator"
 )
 
-type eventType int
-
-const (
-	eventList eventType = iota
-	eventDelete
-	eventUpdate
-	eventCreate
-)
-
-type interaction struct {
-	event     eventType
-	arguments []interface{}
-	values    []interface{}
-}
-
-type mockHandle struct {
-	t      *testing.T
-	script []interaction
-}
-
-func (m *mockHandle) Call(event eventType, numValues int, arguments ...interface{}) []interface{} {
-	require.Greater(m.t, len(m.script), 0)
-
-	require.Equal(m.t, m.script[0].event, event)
-	require.Equal(m.t, len(m.script[0].values), numValues)
-	require.Equal(m.t, m.script[0].arguments, arguments)
-
-	values := m.script[0].values
-	m.script = m.script[1:]
-
-	return values
-}
-
-func (m *mockHandle) IsExhausted() bool {
-	return len(m.script) == 0
-}
-
-func (m *mockHandle) ListRecords(_ context.Context, _ pp.Indent,
-	domain api.FQDN, ipNet ipnet.Type) (map[string]net.IP, bool) {
-	values := m.Call(eventList, 2, domain, ipNet)
-
-	val0, _ := values[0].(map[string]net.IP)
-	val1, _ := values[1].(bool)
-
-	return val0, val1
-}
-
-func (m *mockHandle) DeleteRecord(_ context.Context, _ pp.Indent,
-	domain api.FQDN, ipNet ipnet.Type, id string) bool {
-	values := m.Call(eventDelete, 1, domain, ipNet, id)
-	val0, _ := values[0].(bool)
-	return val0
-}
-
-func (m *mockHandle) UpdateRecord(ctx context.Context, indent pp.Indent,
-	domain api.FQDN, ipNet ipnet.Type, id string, ip net.IP) bool {
-	values := m.Call(eventUpdate, 1, domain, ipNet, id, ip)
-	val0, _ := values[0].(bool)
-	return val0
-}
-
-func (m *mockHandle) CreateRecord(ctx context.Context, indent pp.Indent,
-	domain api.FQDN, ipNet ipnet.Type, ip net.IP, ttl int, proxied bool) (string, bool) {
-	values := m.Call(eventCreate, 2, domain, ipNet, ip, ttl, proxied)
-	val0, _ := values[0].(string)
-	val1, _ := values[1].(bool)
-	return val0, val1
-}
-
-func (m *mockHandle) FlushCache() {
-	require.FailNow(m.t, "updator should never call FlushCache directly")
-}
-
-//nolint: funlen
+//nolint:funlen
 func TestDo(t *testing.T) {
 	t.Parallel()
 
+	type anys = []interface{}
+
 	const (
-		quiet     = quiet.VERBOSE
 		domain    = api.FQDN("sub.test.org")
 		ipNetwork = ipnet.IP6
 		record1   = "record1"
 		record2   = "record2"
 		record3   = "record3"
-		ttl       = 100
+		ttl       = api.TTL(100)
 		proxied   = true
 	)
 	var (
@@ -107,315 +36,265 @@ func TestDo(t *testing.T) {
 	)
 
 	for name, tc := range map[string]struct {
-		ip      net.IP
-		ttl     api.TTL
-		proxied bool
-		script  []interaction
-		ok      bool
+		ip                net.IP
+		ok                bool
+		prepareMockPP     func(m *mocks.MockPP)
+		prepareMockHandle func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle)
 	}{
 		"0-nil": {
-			ip: nil,
-			script: []interaction{
-				{
-					event:     eventList,
-					arguments: []interface{}{domain, ipNetwork},
-					values:    []interface{}{map[string]net.IP{}, true},
-				},
+			nil,
+			true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiAlreadyDone, "The %s records of %q are already up to date", "AAAA", "sub.test.org")
+			},
+			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+				m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]net.IP{}, true)
 			},
-			ok: true,
 		},
 		"0": {
-			ip: ip1,
-			script: []interaction{
-				{
-					event:     eventList,
-					arguments: []interface{}{domain, ipNetwork},
-					values:    []interface{}{map[string]net.IP{}, true},
-				},
-				{
-					event:     eventCreate,
-					arguments: []interface{}{domain, ipNetwork, ip1, ttl, proxied},
-					values:    []interface{}{record1, true},
-				},
+			ip1,
+			true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(pp.EmojiAddRecord, "Added a new %s record of %q (ID: %s)", "AAAA", "sub.test.org", record1)
+			},
+			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+				gomock.InOrder(
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]net.IP{}, true),
+					m.EXPECT().CreateRecord(ctx, ppfmt, domain, ipNetwork, ip1, ttl, proxied).Return(record1, true),
+				)
 			},
-			ok: true,
 		},
 		"1unmatched": {
-			ip: ip1,
-			script: []interaction{
-				{
-					event:     eventList,
-					arguments: []interface{}{domain, ipNetwork},
-					values:    []interface{}{map[string]net.IP{record1: ip2}, true},
-				},
-				{
-					event:     eventUpdate,
-					arguments: []interface{}{domain, ipNetwork, record1, ip1},
-					values:    []interface{}{true},
-				},
+			ip1,
+			true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(pp.EmojiUpdateRecord,
+					"Updated a stale %s record of %q (ID: %s)",
+					"AAAA",
+					"sub.test.org",
+					record1,
+				)
+			},
+			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+				gomock.InOrder(
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]net.IP{record1: ip2}, true),
+					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(true),
+				)
 			},
-			ok: true,
 		},
 		"1unmatched-updatefail": {
-			ip: ip1,
-			script: []interaction{
-				{
-					event:     eventList,
-					arguments: []interface{}{domain, ipNetwork},
-					values:    []interface{}{map[string]net.IP{record1: ip2}, true},
-				},
-				{
-					event:     eventUpdate,
-					arguments: []interface{}{domain, ipNetwork, record1, ip1},
-					values:    []interface{}{false},
-				},
-				{
-					event:     eventDelete,
-					arguments: []interface{}{domain, ipNetwork, record1},
-					values:    []interface{}{true},
-				},
-				{
-					event:     eventCreate,
-					arguments: []interface{}{domain, ipNetwork, ip1, ttl, proxied},
-					values:    []interface{}{record2, true},
-				},
+			ip1,
+			true,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Noticef(pp.EmojiDelRecord, "Deleted a stale %s record of %q (ID: %s)", "AAAA", "sub.test.org", record1),
+					m.EXPECT().Noticef(pp.EmojiAddRecord, "Added a new %s record of %q (ID: %s)", "AAAA", "sub.test.org", record2),
+				)
+			},
+			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+				gomock.InOrder(
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]net.IP{record1: ip2}, true),
+					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(false),
+					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(true),
+					m.EXPECT().CreateRecord(ctx, ppfmt, domain, ipNetwork, ip1, ttl, proxied).Return(record2, true),
+				)
 			},
-			ok: true,
 		},
 		"1unmatched-nil": {
-			ip: nil,
-			script: []interaction{
-				{
-					event:     eventList,
-					arguments: []interface{}{domain, ipNetwork},
-					values:    []interface{}{map[string]net.IP{record1: ip1}, true},
-				},
-				{
-					event:     eventDelete,
-					arguments: []interface{}{domain, ipNetwork, record1},
-					values:    []interface{}{true},
-				},
+			nil,
+			true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(pp.EmojiDelRecord, "Deleted a stale %s record of %q (ID: %s)", "AAAA", "sub.test.org", record1)
+			},
+			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+				gomock.InOrder(
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]net.IP{record1: ip1}, true),
+					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(true),
+				)
 			},
-			ok: true,
 		},
 		"1matched": {
-			ip: ip1,
-			script: []interaction{
-				{
-					event:     eventList,
-					arguments: []interface{}{domain, ipNetwork},
-					values:    []interface{}{map[string]net.IP{record1: ip1}, true},
-				},
+			ip1,
+			true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiAlreadyDone, "The %s records of %q are already up to date", "AAAA", "sub.test.org")
+			},
+			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+				m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]net.IP{record1: ip1}, true)
 			},
-			ok: true,
 		},
 		"2matched": {
-			ip: ip1,
-			script: []interaction{
-				{
-					event:     eventList,
-					arguments: []interface{}{domain, ipNetwork},
-					values:    []interface{}{map[string]net.IP{record1: ip1, record2: ip1}, true},
-				},
-				{
-					event:     eventDelete,
-					arguments: []interface{}{domain, ipNetwork, record2},
-					values:    []interface{}{true},
-				},
+			ip1,
+			true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(
+					pp.EmojiDelRecord,
+					"Deleted a duplicate %s record of %q (ID: %s)",
+					"AAAA",
+					"sub.test.org",
+					record2,
+				)
+			},
+			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+				gomock.InOrder(
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]net.IP{record1: ip1, record2: ip1}, true),
+					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record2).Return(true),
+				)
 			},
-			ok: true,
 		},
 		"2matched-deletefail": {
-			ip: ip1,
-			script: []interaction{
-				{
-					event:     eventList,
-					arguments: []interface{}{domain, ipNetwork},
-					values:    []interface{}{map[string]net.IP{record1: ip1, record2: ip1}, true},
-				},
-				{
-					event:     eventDelete,
-					arguments: []interface{}{domain, ipNetwork, record2},
-					values:    []interface{}{false},
-				},
+			ip1,
+			true,
+			nil,
+			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+				gomock.InOrder(
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]net.IP{record1: ip1, record2: ip1}, true),
+					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record2).Return(false),
+				)
 			},
-			ok: true,
 		},
 		"2unmatched": {
-			ip: ip1,
-			script: []interaction{
-				{
-					event:     eventList,
-					arguments: []interface{}{domain, ipNetwork},
-					values:    []interface{}{map[string]net.IP{record1: ip2, record2: ip2}, true},
-				},
-				{
-					event:     eventUpdate,
-					arguments: []interface{}{domain, ipNetwork, record1, ip1},
-					values:    []interface{}{true},
-				},
-				{
-					event:     eventDelete,
-					arguments: []interface{}{domain, ipNetwork, record2},
-					values:    []interface{}{true},
-				},
+			ip1,
+			true,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Noticef(
+						pp.EmojiUpdateRecord,
+						"Updated a stale %s record of %q (ID: %s)",
+						"AAAA",
+						"sub.test.org",
+						record1,
+					),
+					m.EXPECT().Noticef(pp.EmojiDelRecord, "Deleted a stale %s record of %q (ID: %s)", "AAAA", "sub.test.org", record2),
+				)
+			},
+			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+				gomock.InOrder(
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]net.IP{record1: ip2, record2: ip2}, true),
+					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(true),
+					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record2).Return(true),
+				)
 			},
-			ok: true,
 		},
 		"2unmatched-updatefail": {
-			ip: ip1,
-			script: []interaction{
-				{
-					event:     eventList,
-					arguments: []interface{}{domain, ipNetwork},
-					values:    []interface{}{map[string]net.IP{record1: ip2, record2: ip2}, true},
-				},
-				{
-					event:     eventUpdate,
-					arguments: []interface{}{domain, ipNetwork, record1, ip1},
-					values:    []interface{}{false},
-				},
-				{
-					event:     eventDelete,
-					arguments: []interface{}{domain, ipNetwork, record1},
-					values:    []interface{}{true},
-				},
-				{
-					event:     eventUpdate,
-					arguments: []interface{}{domain, ipNetwork, record2, ip1},
-					values:    []interface{}{true},
-				},
+			ip1,
+			true,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Noticef(pp.EmojiDelRecord, "Deleted a stale %s record of %q (ID: %s)", "AAAA", "sub.test.org", record1),
+					m.EXPECT().Noticef(
+						pp.EmojiUpdateRecord,
+						"Updated a stale %s record of %q (ID: %s)",
+						"AAAA",
+						"sub.test.org",
+						record2,
+					),
+				)
+			},
+			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+				gomock.InOrder(
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]net.IP{record1: ip2, record2: ip2}, true),
+					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(false),
+					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(true),
+					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record2, ip1).Return(true),
+				)
 			},
-			ok: true,
 		},
-		"2unmatched-updatefailtwice": { //nolint: dupl // they are similar but different tests
-			ip: ip1,
-			script: []interaction{
-				{
-					event:     eventList,
-					arguments: []interface{}{domain, ipNetwork},
-					values:    []interface{}{map[string]net.IP{record1: ip2, record2: ip2}, true},
-				},
-				{
-					event:     eventUpdate,
-					arguments: []interface{}{domain, ipNetwork, record1, ip1},
-					values:    []interface{}{false},
-				},
-				{
-					event:     eventDelete,
-					arguments: []interface{}{domain, ipNetwork, record1},
-					values:    []interface{}{true},
-				},
-				{
-					event:     eventUpdate,
-					arguments: []interface{}{domain, ipNetwork, record2, ip1},
-					values:    []interface{}{false},
-				},
-				{
-					event:     eventDelete,
-					arguments: []interface{}{domain, ipNetwork, record2},
-					values:    []interface{}{true},
-				},
-				{
-					event:     eventCreate,
-					arguments: []interface{}{domain, ipNetwork, ip1, ttl, proxied},
-					values:    []interface{}{record3, true},
-				},
+		"2unmatched-updatefailtwice": {
+			ip1,
+			true,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Noticef(pp.EmojiDelRecord, "Deleted a stale %s record of %q (ID: %s)", "AAAA", "sub.test.org", record1),
+					m.EXPECT().Noticef(pp.EmojiDelRecord, "Deleted a stale %s record of %q (ID: %s)", "AAAA", "sub.test.org", record2),
+					m.EXPECT().Noticef(pp.EmojiAddRecord, "Added a new %s record of %q (ID: %s)", "AAAA", "sub.test.org", record3),
+				)
+			},
+			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+				gomock.InOrder(
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]net.IP{record1: ip2, record2: ip2}, true),
+					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(false),
+					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(true),
+					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record2, ip1).Return(false),
+					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record2).Return(true),
+					m.EXPECT().CreateRecord(ctx, ppfmt, domain, ipNetwork, ip1, ttl, proxied).Return(record3, true),
+				)
 			},
-			ok: true,
 		},
-		"2unmatched-updatefail-deletefail-updatefail": { //nolint: dupl // they are similar but different tests
-			ip: ip1,
-			script: []interaction{
-				{
-					event:     eventList,
-					arguments: []interface{}{domain, ipNetwork},
-					values:    []interface{}{map[string]net.IP{record1: ip2, record2: ip2}, true},
-				},
-				{
-					event:     eventUpdate,
-					arguments: []interface{}{domain, ipNetwork, record1, ip1},
-					values:    []interface{}{false},
-				},
-				{
-					event:     eventDelete,
-					arguments: []interface{}{domain, ipNetwork, record1},
-					values:    []interface{}{false},
-				},
-				{
-					event:     eventUpdate,
-					arguments: []interface{}{domain, ipNetwork, record2, ip1},
-					values:    []interface{}{false},
-				},
-				{
-					event:     eventDelete,
-					arguments: []interface{}{domain, ipNetwork, record2},
-					values:    []interface{}{true},
-				},
-				{
-					event:     eventCreate,
-					arguments: []interface{}{domain, ipNetwork, ip1, ttl, proxied},
-					values:    []interface{}{record3, true},
-				},
+		//nolint:dupl
+		"2unmatched-updatefail-deletefail-updatefail": {
+			ip1,
+			false,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Noticef(pp.EmojiDelRecord, "Deleted a stale %s record of %q (ID: %s)", "AAAA", "sub.test.org", record2),
+					m.EXPECT().Noticef(pp.EmojiAddRecord, "Added a new %s record of %q (ID: %s)", "AAAA", "sub.test.org", record3),
+					m.EXPECT().Errorf(pp.EmojiError, "Failed to (fully) update %s records of %q", "AAAA", "sub.test.org"),
+				)
+			},
+			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+				gomock.InOrder(
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]net.IP{record1: ip2, record2: ip2}, true),
+					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(false),
+					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(false),
+					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record2, ip1).Return(false),
+					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record2).Return(true),
+					m.EXPECT().CreateRecord(ctx, ppfmt, domain, ipNetwork, ip1, ttl, proxied).Return(record3, true),
+				)
 			},
-			ok: false,
 		},
-		"2unmatched-updatefailtwice-createfail": { //nolint: dupl // they are similar but different tests
-			ip: ip1,
-			script: []interaction{
-				{
-					event:     eventList,
-					arguments: []interface{}{domain, ipNetwork},
-					values:    []interface{}{map[string]net.IP{record1: ip2, record2: ip2}, true},
-				},
-				{
-					event:     eventUpdate,
-					arguments: []interface{}{domain, ipNetwork, record1, ip1},
-					values:    []interface{}{false},
-				},
-				{
-					event:     eventDelete,
-					arguments: []interface{}{domain, ipNetwork, record1},
-					values:    []interface{}{true},
-				},
-				{
-					event:     eventUpdate,
-					arguments: []interface{}{domain, ipNetwork, record2, ip1},
-					values:    []interface{}{false},
-				},
-				{
-					event:     eventDelete,
-					arguments: []interface{}{domain, ipNetwork, record2},
-					values:    []interface{}{true},
-				},
-				{
-					event:     eventCreate,
-					arguments: []interface{}{domain, ipNetwork, ip1, ttl, proxied},
-					values:    []interface{}{record3, false},
-				},
+		//nolint:dupl
+		"2unmatched-updatefailtwice-createfail": {
+			ip1,
+			false,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Noticef(pp.EmojiDelRecord, "Deleted a stale %s record of %q (ID: %s)", "AAAA", "sub.test.org", record1),
+					m.EXPECT().Noticef(pp.EmojiDelRecord, "Deleted a stale %s record of %q (ID: %s)", "AAAA", "sub.test.org", record2),
+					m.EXPECT().Errorf(pp.EmojiError, "Failed to (fully) update %s records of %q", "AAAA", "sub.test.org"),
+				)
+			},
+			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+				gomock.InOrder(
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]net.IP{record1: ip2, record2: ip2}, true),
+					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record1, ip1).Return(false),
+					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(true),
+					m.EXPECT().UpdateRecord(ctx, ppfmt, domain, ipNetwork, record2, ip1).Return(false),
+					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record2).Return(true),
+					m.EXPECT().CreateRecord(ctx, ppfmt, domain, ipNetwork, ip1, ttl, proxied).Return(record3, false),
+				)
 			},
-			ok: false,
 		},
 		"listfail": {
-			ip: ip1,
-			script: []interaction{
-				{
-					event:     eventList,
-					arguments: []interface{}{domain, ipNetwork},
-					values:    []interface{}{nil, false},
-				},
+			ip1,
+			false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiError, "Failed to (fully) update %s records of %q", "AAAA", "sub.test.org")
+			},
+			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+				m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(nil, false)
 			},
-			ok: false,
 		},
 	} {
 		tc := tc
 		t.Run(name, func(t *testing.T) {
 			t.Parallel()
-			m := &mockHandle{t: t, script: tc.script}
-			ok := updator.Do(context.Background(), 3,
-				quiet,
+			mockCtrl := gomock.NewController(t)
+
+			ctx := context.Background()
+
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			mockHandle := mocks.NewMockHandle(mockCtrl)
+			if tc.prepareMockHandle != nil {
+				tc.prepareMockHandle(ctx, mockPP, mockHandle)
+			}
+
+			ok := updator.Do(ctx, mockPP,
 				&updator.Args{
-					Handle:    m,
+					Handle:    mockHandle,
 					IPNetwork: ipNetwork,
 					IP:        tc.ip,
 					Domain:    domain,
@@ -423,7 +302,6 @@ func TestDo(t *testing.T) {
 					Proxied:   proxied,
 				})
 			require.Equal(t, tc.ok, ok)
-			require.True(t, m.IsExhausted())
 		})
 	}
 }