Repository: favonia/cloudflare-ddns
Pull request #762 (2024-06-22 00:36:56)

refactor(updater): smart message generation (part 3 of shoutrrr support)

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/762?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) Report
Attention: Patch coverage is `66.33166%` with `67 lines` in your changes missing coverage. Please review.
> Project coverage is 86.50%. Comparing base [(`dc18a68`)](https://app.codecov.io/gh/favonia/cloudflare-ddns/commit/dc18a684cd9ca6090f594410635a9d92af3c48cb?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) to head [(`2ca5a32`)](https://app.codecov.io/gh/favonia/cloudflare-ddns/commit/2ca5a32f4c55502d4858f76829446cd1789f880e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).

| [Files](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/762?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | Patch % | Lines |
|---|---|---|
| [internal/updater/setter\_responses.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/762?src=pr&el=tree&filepath=internal%2Fupdater%2Fsetter_responses.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvdXBkYXRlci9zZXR0ZXJfcmVzcG9uc2VzLmdv) | 66.10% | [40 Missing :warning: ](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/762?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) |
| [cmd/ddns/ddns.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/762?src=pr&el=tree&filepath=cmd%2Fddns%2Fddns.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-Y21kL2RkbnMvZGRucy5nbw==) | 0.00% | [9 Missing :warning: ](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/762?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) |
| [internal/monitor/base.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/762?src=pr&el=tree&filepath=internal%2Fmonitor%2Fbase.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvbW9uaXRvci9iYXNlLmdv) | 0.00% | [6 Missing :warning: ](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/762?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) |
| [internal/monitor/composite.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/762?src=pr&el=tree&filepath=internal%2Fmonitor%2Fcomposite.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvbW9uaXRvci9jb21wb3NpdGUuZ28=) | 45.45% | [6 Missing :warning: ](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/762?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) |
| [internal/monitor/healthchecks.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/762?src=pr&el=tree&filepath=internal%2Fmonitor%2Fhealthchecks.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvbW9uaXRvci9oZWFsdGhjaGVja3MuZ28=) | 0.00% | [6 Missing :warning: ](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/762?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #762      +/-   ##
==========================================
- Coverage   88.40%   86.50%   -1.90%     
==========================================
  Files          48       51       +3     
  Lines        2000     2119     +119     
==========================================
+ Hits         1768     1833      +65     
- Misses        210      264      +54     
  Partials       22       22              
```

| [Flag](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/762/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/762/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | `86.50% <66.33%> (-1.90%)` | :arrow_down: |

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.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/762?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).


Patch:
diff --git a/cmd/ddns/ddns.go b/cmd/ddns/ddns.go
index e736739..f86d13d 100644
--- a/cmd/ddns/ddns.go
+++ b/cmd/ddns/ddns.go
@@ -55,11 +55,8 @@ func initConfig(ctx context.Context, ppfmt pp.PP) (*config.Config, setter.Setter
 
 func stopUpdating(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter) {
 	if c.DeleteOnStop {
-		if ok, msg := updater.DeleteIPs(ctx, ppfmt, c, s); ok {
-			monitor.LogAll(ctx, ppfmt, msg, c.Monitors)
-		} else {
-			monitor.FailureAll(ctx, ppfmt, msg, c.Monitors)
-		}
+		resp := updater.DeleteIPs(ctx, ppfmt, c, s)
+		monitor.SendAll(ctx, ppfmt, c.Monitors, resp)
 	}
 }
 
@@ -94,10 +91,10 @@ func realMain() int { //nolint:funlen
 	// Read the config and get the handler and the setter
 	c, s, configOk := initConfig(ctx, ppfmt)
 	// Ping the monitor regardless of whether initConfig succeeded
-	monitor.StartAll(ctx, ppfmt, formatName(), c.Monitors)
+	monitor.StartAll(ctx, ppfmt, c.Monitors, formatName())
 	// Bail out now if initConfig failed
 	if !configOk {
-		monitor.ExitStatusAll(ctx, ppfmt, 1, "Config errors", c.Monitors)
+		monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 1, "Config errors")
 		ppfmt.Infof(pp.EmojiBye, "Bye!")
 		return 1
 	}
@@ -121,13 +118,10 @@ func realMain() int { //nolint:funlen
 
 		// Update the IP addresses
 		if first && !c.UpdateOnStart {
-			monitor.SuccessAll(ctx, ppfmt, "Started (no action)", c.Monitors)
+			monitor.SuccessAll(ctx, ppfmt, c.Monitors, "Started (no action)")
 		} else {
-			if ok, msg := updater.UpdateIPs(ctxWithSignals, ppfmt, c, s); ok {
-				monitor.SuccessAll(ctx, ppfmt, msg, c.Monitors)
-			} else {
-				monitor.FailureAll(ctx, ppfmt, msg, c.Monitors)
-			}
+			resp := updater.UpdateIPs(ctxWithSignals, ppfmt, c, s)
+			monitor.SendAll(ctx, ppfmt, c.Monitors, resp)
 		}
 
 		// Check if cron was disabled
@@ -142,7 +136,7 @@ func realMain() int { //nolint:funlen
 		if next.IsZero() {
 			ppfmt.Errorf(pp.EmojiUserError, "No scheduled updates in near future")
 			stopUpdating(ctx, ppfmt, c, s)
-			monitor.ExitStatusAll(ctx, ppfmt, 1, "No scheduled updates", c.Monitors)
+			monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 1, "No scheduled updates")
 			ppfmt.Infof(pp.EmojiBye, "Bye!")
 			return 1
 		}
@@ -153,7 +147,7 @@ func realMain() int { //nolint:funlen
 		// Wait for the next signal or the alarm, whichever comes first
 		if !sig.SleepUntil(ppfmt, next) {
 			stopUpdating(ctx, ppfmt, c, s)
-			monitor.ExitStatusAll(ctx, ppfmt, 0, "Terminated", c.Monitors)
+			monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 0, "Terminated")
 			ppfmt.Infof(pp.EmojiBye, "Bye!")
 			return 0
 		}
diff --git a/internal/monitor/base.go b/internal/monitor/base.go
index a16083b..613e143 100644
--- a/internal/monitor/base.go
+++ b/internal/monitor/base.go
@@ -3,8 +3,10 @@ package monitor
 
 import (
 	"context"
+	"strings"
 
 	"github.com/favonia/cloudflare-ddns/internal/pp"
+	"github.com/favonia/cloudflare-ddns/internal/response"
 )
 
 //go:generate mockgen -typed -destination=../mocks/mock_monitor.go -package=mocks . Monitor
@@ -33,3 +35,12 @@ type Monitor interface {
 	// ExitStatus records the exit status (as an integer in the POSIX style).
 	ExitStatus(ctx context.Context, ppfmt pp.PP, code int, message string) bool
 }
+
+func Send(ctx context.Context, ppfmt pp.PP, m Monitor, r response.Response) bool {
+	msg := strings.Join(r.MonitorMessages, "\n")
+	if r.Ok {
+		return m.Log(ctx, ppfmt, msg)
+	} else {
+		return m.Failure(ctx, ppfmt, msg)
+	}
+}
diff --git a/internal/monitor/composite.go b/internal/monitor/composite.go
index a6e5aac..76ef212 100644
--- a/internal/monitor/composite.go
+++ b/internal/monitor/composite.go
@@ -4,6 +4,7 @@ import (
 	"context"
 
 	"github.com/favonia/cloudflare-ddns/internal/pp"
+	"github.com/favonia/cloudflare-ddns/internal/response"
 )
 
 // DescribeAll calls [Monitor.Describe] for each monitor in the group with the callback.
@@ -14,7 +15,7 @@ func DescribeAll(callback func(service, params string), ms []Monitor) {
 }
 
 // SuccessAll calls [Monitor.Success] for each monitor in the group.
-func SuccessAll(ctx context.Context, ppfmt pp.PP, message string, ms []Monitor) bool {
+func SuccessAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, message string) bool {
 	ok := true
 	for _, m := range ms {
 		if !m.Success(ctx, ppfmt, message) {
@@ -25,7 +26,7 @@ func SuccessAll(ctx context.Context, ppfmt pp.PP, message string, ms []Monitor)
 }
 
 // StartAll calls [Monitor.Start] for each monitor in ms.
-func StartAll(ctx context.Context, ppfmt pp.PP, message string, ms []Monitor) bool {
+func StartAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, message string) bool {
 	ok := true
 	for _, m := range ms {
 		if !m.Start(ctx, ppfmt, message) {
@@ -36,7 +37,7 @@ func StartAll(ctx context.Context, ppfmt pp.PP, message string, ms []Monitor) bo
 }
 
 // FailureAll calls [Monitor.Failure] for each monitor in ms.
-func FailureAll(ctx context.Context, ppfmt pp.PP, message string, ms []Monitor) bool {
+func FailureAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, message string) bool {
 	ok := true
 	for _, m := range ms {
 		if !m.Failure(ctx, ppfmt, message) {
@@ -47,7 +48,7 @@ func FailureAll(ctx context.Context, ppfmt pp.PP, message string, ms []Monitor)
 }
 
 // LogAll calls [Monitor.Log] for each monitor in ms.
-func LogAll(ctx context.Context, ppfmt pp.PP, message string, ms []Monitor) bool {
+func LogAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, message string) bool {
 	ok := true
 	for _, m := range ms {
 		if !m.Log(ctx, ppfmt, message) {
@@ -58,7 +59,7 @@ func LogAll(ctx context.Context, ppfmt pp.PP, message string, ms []Monitor) bool
 }
 
 // ExitStatusAll calls [Monitor.ExitStatus] for each monitor in ms.
-func ExitStatusAll(ctx context.Context, ppfmt pp.PP, code int, message string, ms []Monitor) bool {
+func ExitStatusAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, code int, message string) bool {
 	ok := true
 	for _, m := range ms {
 		if !m.ExitStatus(ctx, ppfmt, code, message) {
@@ -67,3 +68,14 @@ func ExitStatusAll(ctx context.Context, ppfmt pp.PP, code int, message string, m
 	}
 	return ok
 }
+
+// SendAll calls [Send] for each monitor in ms.
+func SendAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, resp response.Response) bool {
+	ok := true
+	for _, m := range ms {
+		if !Send(ctx, ppfmt, m, resp) {
+			ok = false
+		}
+	}
+	return ok
+}
diff --git a/internal/response/response.go b/internal/response/response.go
new file mode 100644
index 0000000..e183d7d
--- /dev/null
+++ b/internal/response/response.go
@@ -0,0 +1,35 @@
+package response
+
+type Response struct {
+	Ok               bool
+	MonitorMessages  []string
+	NotifierMessages []string
+}
+
+func NewEmpty() Response {
+	return Response{
+		Ok:               true,
+		MonitorMessages:  nil,
+		NotifierMessages: nil,
+	}
+}
+
+func Merge(rs ...Response) Response {
+	var (
+		allOk               = true
+		allMonitorMessages  = map[bool][]string{true: {}, false: {}}
+		allNotifierMessages = []string{}
+	)
+
+	for _, r := range rs {
+		allOk = allOk && r.Ok
+		allMonitorMessages[r.Ok] = append(allMonitorMessages[r.Ok], r.MonitorMessages...)
+		allNotifierMessages = append(allNotifierMessages, r.NotifierMessages...)
+	}
+
+	return Response{
+		Ok:               allOk,
+		MonitorMessages:  allMonitorMessages[allOk],
+		NotifierMessages: allNotifierMessages,
+	}
+}
diff --git a/internal/setter/base.go b/internal/setter/base.go
index eb09535..0d3e465 100644
--- a/internal/setter/base.go
+++ b/internal/setter/base.go
@@ -17,18 +17,6 @@ import (
 
 //go:generate mockgen -typed -destination=../mocks/mock_setter.go -package=mocks . Setter
 
-// ResponseCode encodes the minimum information to generate messages for monitors and notifiers.
-type ResponseCode int
-
-const (
-	// No updates were needed. The records were already okay.
-	ResponseNoUpdatesNeeded = iota
-	// Updates were needed and they are done.
-	ResponseUpdatesApplied
-	// Updates were needed and they did not fully complete. The records may be inconsistent.
-	ResponseUpdatesFailed
-)
-
 // Setter uses [api.Handle] to update DNS records.
 type Setter interface {
 	// Set sets a particular domain to the given IP address.
diff --git a/internal/setter/code.go b/internal/setter/code.go
new file mode 100644
index 0000000..136479f
--- /dev/null
+++ b/internal/setter/code.go
@@ -0,0 +1,20 @@
+package setter
+
+// ResponseCode encodes the minimum information to generate messages for monitors and notifiers.
+type ResponseCode int
+
+const (
+	// ResponseNoop means no changes were needed.
+	// The records were already updated or already deleted.
+	ResponseNoop ResponseCode = iota
+
+	// ResponseUpdated means records should be updated
+	// and we updated them, or that they should be deleted
+	// and we deleted them.
+	ResponseUpdated
+
+	// ResponseFailed means records should be updated
+	// but we failed to finish the updating, or that they
+	// should be deleted and we failed to finish the deletion.
+	ResponseFailed
+)
diff --git a/internal/setter/setter.go b/internal/setter/setter.go
index ccac2d4..1453586 100644
--- a/internal/setter/setter.go
+++ b/internal/setter/setter.go
@@ -55,7 +55,7 @@ func (s *setter) Set(ctx context.Context, ppfmt pp.PP,
 	rs, ok := s.Handle.ListRecords(ctx, ppfmt, domain, ipnet)
 	if !ok {
 		ppfmt.Errorf(pp.EmojiError, "Failed to retrieve the current %s records of %q", recordType, domainDescription)
-		return ResponseUpdatesFailed
+		return ResponseFailed
 	}
 
 	// The intention of these two lists is to find or create a good record and then delete everything else.
@@ -77,7 +77,7 @@ func (s *setter) Set(ctx context.Context, ppfmt pp.PP,
 	// If it's up to date and there are no other records, we are done!
 	if foundMatched && len(unprocessedMatched) == 0 && len(unprocessedUnmatched) == 0 {
 		ppfmt.Infof(pp.EmojiAlreadyDone, "The %s records of %q are already up to date", recordType, domainDescription)
-		return ResponseNoUpdatesNeeded
+		return ResponseNoop
 	}
 
 	// This counts the stale records that have not being deleted yet.
@@ -164,12 +164,12 @@ func (s *setter) Set(ctx context.Context, ppfmt pp.PP,
 	// Check whether we are done. It is okay to have duplicates, but it is not okay to have remaining stale records.
 	if !foundMatched || numUndeletedUnmatched > 0 {
 		ppfmt.Errorf(pp.EmojiError,
-			"Failed to complete updating of %s records of %q; records might be inconsistent",
+			"Failed to finish updating %s records of %q; records might be inconsistent",
 			recordType, domainDescription)
-		return ResponseUpdatesFailed
+		return ResponseFailed
 	}
 
-	return ResponseUpdatesApplied
+	return ResponseUpdated
 }
 
 // Delete deletes all managed DNS records.
@@ -180,7 +180,7 @@ func (s *setter) Delete(ctx context.Context, ppfmt pp.PP, domain domain.Domain,
 	rmap, ok := s.Handle.ListRecords(ctx, ppfmt, domain, ipnet)
 	if !ok {
 		ppfmt.Errorf(pp.EmojiError, "Failed to retrieve the current %s records of %q", recordType, domainDescription)
-		return ResponseUpdatesFailed
+		return ResponseFailed
 	}
 
 	// Sorting is not needed for correctness, but it will make the function deterministic.
@@ -192,7 +192,7 @@ func (s *setter) Delete(ctx context.Context, ppfmt pp.PP, domain domain.Domain,
 
 	if len(unmatchedIDs) == 0 {
 		ppfmt.Infof(pp.EmojiAlreadyDone, "The %s records of %q were already deleted", recordType, domainDescription)
-		return ResponseNoUpdatesNeeded
+		return ResponseNoop
 	}
 
 	allOk := true
@@ -206,10 +206,10 @@ func (s *setter) Delete(ctx context.Context, ppfmt pp.PP, domain domain.Domain,
 	}
 	if !allOk {
 		ppfmt.Errorf(pp.EmojiError,
-			"Failed to complete deleting of %s records of %q; records might be inconsistent",
+			"Failed to finish deleting %s records of %q; records might be inconsistent",
 			recordType, domainDescription)
-		return ResponseUpdatesFailed
+		return ResponseFailed
 	}
 
-	return ResponseUpdatesApplied
+	return ResponseUpdated
 }
diff --git a/internal/updater/response.go b/internal/updater/response.go
new file mode 100644
index 0000000..8a4fad0
--- /dev/null
+++ b/internal/updater/response.go
@@ -0,0 +1,158 @@
+package updater
+
+import (
+	"fmt"
+	"net/netip"
+	"strings"
+
+	"github.com/favonia/cloudflare-ddns/internal/domain"
+	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/response"
+	"github.com/favonia/cloudflare-ddns/internal/setter"
+)
+
+type SetterResponses map[setter.ResponseCode][]string
+
+func (s SetterResponses) Register(code setter.ResponseCode, d domain.Domain) {
+	s[code] = append(s[code], d.Describe())
+}
+
+func ListJoin(items []string) string { return strings.Join(items, ", ") }
+func ListEnglishJoin(items []string) string {
+	switch l := len(items); l {
+	case 0:
+		return "(none)"
+	case 1:
+		return items[0]
+	case 2: //nolint:mnd
+		return fmt.Sprintf("%s and %s", items[0], items[1])
+	default:
+		return fmt.Sprintf("%s, and %s", strings.Join(items[:l-1], ", "), items[l-1])
+	}
+}
+
+func GenerateDetectResponse(ipNet ipnet.Type, ok bool) response.Response {
+	if ok {
+		return response.NewEmpty()
+	}
+
+	return response.Response{
+		Ok:               false,
+		MonitorMessages:  []string{fmt.Sprintf("Failed to detect %s address", ipNet.Describe())},
+		NotifierMessages: []string{fmt.Sprintf("Failed to detect the %s address.", ipNet.Describe())},
+	}
+}
+
+func GenerateUpdateResponse(ipNet ipnet.Type, ip netip.Addr, s SetterResponses) response.Response {
+	switch {
+	case len(s[setter.ResponseFailed]) > 0 &&
+		len(s[setter.ResponseUpdated]) > 0:
+		return response.Response{
+			Ok: false,
+			MonitorMessages: []string{fmt.Sprintf(
+				"Failed to set %s (%s): %s",
+				ipNet.RecordType(),
+				ip.String(),
+				ListJoin(s[setter.ResponseFailed]),
+			)},
+			NotifierMessages: []string{fmt.Sprintf(
+				"Failed to finish updating %s records of %s with %s; those of %s were updated.",
+				ipNet.RecordType(),
+				ListEnglishJoin(s[setter.ResponseFailed]),
+				ip.String(),
+				ListEnglishJoin(s[setter.ResponseUpdated]),
+			)},
+		}
+
+	case len(s[setter.ResponseFailed]) > 0:
+		return response.Response{
+			Ok: false,
+			MonitorMessages: []string{fmt.Sprintf(
+				"Failed to set %s (%s): %s",
+				ipNet.RecordType(),
+				ip.String(),
+				ListJoin(s[setter.ResponseFailed]),
+			)},
+			NotifierMessages: []string{fmt.Sprintf(
+				"Failed to finish updating %s records of %s with %s.",
+				ipNet.RecordType(),
+				ListEnglishJoin(s[setter.ResponseFailed]),
+				ip.String(),
+			)},
+		}
+
+	case len(s[setter.ResponseUpdated]) > 0:
+		return response.Response{
+			Ok: true,
+			MonitorMessages: []string{fmt.Sprintf(
+				"Set %s (%s): %s",
+				ipNet.RecordType(),
+				ip.String(),
+				ListJoin(s[setter.ResponseUpdated]),
+			)},
+			NotifierMessages: []string{fmt.Sprintf(
+				"Updated %s records of %s with %s.",
+				ipNet.RecordType(),
+				ListEnglishJoin(s[setter.ResponseUpdated]),
+				ip.String(),
+			)},
+		}
+
+	default:
+		return response.Response{Ok: true, MonitorMessages: []string{}, NotifierMessages: []string{}}
+	}
+}
+
+func GenerateDeleteResponse(ipNet ipnet.Type, s SetterResponses) response.Response {
+	switch {
+	case len(s[setter.ResponseFailed]) > 0 &&
+		len(s[setter.ResponseUpdated]) > 0:
+		return response.Response{
+			Ok: false,
+			MonitorMessages: []string{fmt.Sprintf(
+				"Failed to delete %s: %s",
+				ipNet.RecordType(),
+				ListJoin(s[setter.ResponseFailed]),
+			)},
+			NotifierMessages: []string{fmt.Sprintf(
+				"Failed to finish deleting %s records of %s; those of %s were deleted.",
+				ipNet.RecordType(),
+				ListEnglishJoin(s[setter.ResponseFailed]),
+				ListEnglishJoin(s[setter.ResponseUpdated]),
+			)},
+		}
+
+	case len(s[setter.ResponseFailed]) > 0:
+		return response.Response{
+			Ok: false,
+			MonitorMessages: []string{fmt.Sprintf(
+				"Failed to delete %s: %s",
+				ipNet.RecordType(),
+				ListJoin(s[setter.ResponseFailed]),
+			)},
+			NotifierMessages: []string{fmt.Sprintf(
+				"Failed to finish deleting %s records of %s.",
+				ipNet.RecordType(),
+				ListEnglishJoin(s[setter.ResponseFailed]),
+			)},
+		}
+
+	case len(s[setter.ResponseUpdated]) > 0:
+		return response.Response{
+			Ok: true,
+			MonitorMessages: []string{fmt.Sprintf(
+				"Deleted %s: %s",
+				ipNet.RecordType(),
+				ListJoin(s[setter.ResponseUpdated]),
+			)},
+			NotifierMessages: []string{fmt.Sprintf(
+				"Deleted %s records of %s.",
+				ipNet.RecordType(),
+				ListEnglishJoin(s[setter.ResponseUpdated]),
+			)},
+		}
+
+	default:
+		return response.Response{Ok: true, MonitorMessages: []string{}, NotifierMessages: []string{}}
+	}
+}
diff --git a/internal/updater/updater.go b/internal/updater/updater.go
index ec0f50d..4398702 100644
--- a/internal/updater/updater.go
+++ b/internal/updater/updater.go
@@ -5,14 +5,13 @@ package updater
 import (
 	"context"
 	"errors"
-	"fmt"
 	"net/netip"
-	"strings"
 
 	"github.com/favonia/cloudflare-ddns/internal/config"
 	"github.com/favonia/cloudflare-ddns/internal/domain"
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
+	"github.com/favonia/cloudflare-ddns/internal/response"
 	"github.com/favonia/cloudflare-ddns/internal/setter"
 )
 
@@ -53,75 +52,56 @@ var errSettingTimeout = errors.New("setting timeout")
 // ip must be non-zero.
 func setIP(ctx context.Context, ppfmt pp.PP,
 	c *config.Config, s setter.Setter, ipNet ipnet.Type, ip netip.Addr,
-) (bool, []string) {
-	allOk := true
-
-	// [msgs[false]] collects all the error messages and [msgs[true]] collects all the success messages.
-	msgs := map[bool][]string{}
+) response.Response {
+	resps := SetterResponses{}
 
 	for _, domain := range c.Domains[ipNet] {
 		ctx, cancel := context.WithTimeoutCause(ctx, c.UpdateTimeout, errSettingTimeout)
 		defer cancel()
 
 		resp := s.Set(ctx, ppfmt, domain, ipNet, ip, c.TTL, getProxied(ppfmt, c, domain))
-		switch resp {
-		case setter.ResponseUpdatesApplied:
-			msgs[true] = append(msgs[true], fmt.Sprintf("Set %s %s to %s", domain.Describe(), ipNet.RecordType(), ip.String()))
-		case setter.ResponseUpdatesFailed:
-			allOk = false
-			msgs[false] = append(msgs[false], fmt.Sprintf("Failed to set %s %s", domain.Describe(), ipNet.RecordType()))
+		resps.Register(resp, domain)
+		if resp == setter.ResponseFailed {
 			if ShouldDisplayHints["update-timeout"] && errors.Is(context.Cause(ctx), errSettingTimeout) {
 				ppfmt.Infof(pp.EmojiHint,
 					"If your network is working but with high latency, consider increasing the value of UPDATE_TIMEOUT",
 				)
 				ShouldDisplayHints["update-timeout"] = false
 			}
-		case setter.ResponseNoUpdatesNeeded:
 		}
 	}
 
-	return allOk, msgs[allOk]
+	return GenerateUpdateResponse(ipNet, ip, resps)
 }
 
-// deleteIP extracts relevant settings from the configuration and calls [setter.Setter.Clear] with a deadline.
-func deleteIP(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter, ipNet ipnet.Type) (bool, []string) {
-	allOk := true
-
-	// [msgs[false]] collects all the error messages and [msgs[true]] collects all the success messages.
-	msgs := map[bool][]string{}
+// deleteIP extracts relevant settings from the configuration and calls [setter.Setter.Delete] with a deadline.
+func deleteIP(
+	ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter, ipNet ipnet.Type,
+) response.Response {
+	resps := SetterResponses{}
 
 	for _, domain := range c.Domains[ipNet] {
 		ctx, cancel := context.WithTimeout(ctx, c.UpdateTimeout)
 		defer cancel()
 
 		resp := s.Delete(ctx, ppfmt, domain, ipNet)
-		switch resp {
-		case setter.ResponseUpdatesApplied:
-			msgs[true] = append(msgs[true], fmt.Sprintf("Deleted %s %s", domain.Describe(), ipNet.RecordType()))
-		case setter.ResponseUpdatesFailed:
-			allOk = false
-			msgs[false] = append(msgs[false], fmt.Sprintf("Failed to delete %s %s", domain.Describe(), ipNet.RecordType()))
-		case setter.ResponseNoUpdatesNeeded:
-		}
+		resps.Register(resp, domain)
 	}
 
-	return allOk, msgs[allOk]
+	return GenerateDeleteResponse(ipNet, resps)
 }
 
 func detectIP(ctx context.Context, ppfmt pp.PP,
 	c *config.Config, ipNet ipnet.Type, use1001 bool,
-) (netip.Addr, bool, []string) {
+) (netip.Addr, bool) {
 	ctx, cancel := context.WithTimeout(ctx, c.DetectionTimeout)
 	defer cancel()
 
-	var msgs []string
 	ip, ok := c.Provider[ipNet].GetIP(ctx, ppfmt, ipNet, use1001)
 	if ok {
 		ppfmt.Infof(pp.EmojiInternet, "Detected the %s address: %v", ipNet.Describe(), ip)
 	} else {
-		msg := fmt.Sprintf("Failed to detect the %s address", ipNet.Describe())
-		msgs = append(msgs, msg)
-		ppfmt.Errorf(pp.EmojiError, "%s", msg)
+		ppfmt.Errorf(pp.EmojiError, "Failed to detect the %s address", ipNet.Describe())
 		if ShouldDisplayHints[getHintIDForDetection(ipNet)] {
 			switch ipNet {
 			case ipnet.IP6:
@@ -134,55 +114,37 @@ func detectIP(ctx context.Context, ppfmt pp.PP,
 		}
 	}
 	ShouldDisplayHints[getHintIDForDetection(ipNet)] = false
-	return ip, ok, msgs
+	return ip, ok
 }
 
 // UpdateIPs detect IP addresses and update DNS records of managed domains.
-func UpdateIPs(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter) (bool, string) {
-	allOk := true
-
-	// [msgs[false]] collects all the error messages and [msgs[true]] collects all the success messages.
-	msgs := map[bool][]string{}
+func UpdateIPs(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter) response.Response {
+	var resps []response.Response
 
 	for _, ipNet := range [...]ipnet.Type{ipnet.IP4, ipnet.IP6} {
 		if c.Provider[ipNet] != nil {
-			ip, ok, msg := detectIP(ctx, ppfmt, c, ipNet, c.Use1001)
-			msgs[ok] = append(msgs[ok], msg...)
-			if !ok {
-				// We can't detect the new IP address. It's probably better to leave existing IP addresses alone.
-				allOk = false
-				continue
-			}
+			ip, ok := detectIP(ctx, ppfmt, c, ipNet, c.Use1001)
+			resps = append(resps, GenerateDetectResponse(ipNet, ok))
 
-			ok, msg = setIP(ctx, ppfmt, c, s, ipNet, ip)
-			msgs[ok] = append(msgs[ok], msg...)
-			allOk = allOk && ok
+			// Note: If we can't detect the new IP address,
+			// it's probably better to leave existing records alone.
+			if ok {
+				resps = append(resps, setIP(ctx, ppfmt, c, s, ipNet, ip))
+			}
 		}
 	}
-
-	var allMsg string
-	if len(msgs[false]) != 0 {
-		allMsg = strings.Join(msgs[false], "\n")
-	} else {
-		allMsg = strings.Join(msgs[true], "\n")
-	}
-	return allOk, allMsg
+	return response.Merge(resps...)
 }
 
 // DeleteIPs removes all DNS records of managed domains.
-func DeleteIPs(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter) (bool, string) {
-	allOk := true
-
-	// [msgs[false]] collects all the error messages and [msgs[true]] collects all the success messages.
-	msgs := map[bool][]string{}
+func DeleteIPs(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter) response.Response {
+	var resps []response.Response
 
 	for _, ipNet := range [...]ipnet.Type{ipnet.IP4, ipnet.IP6} {
 		if c.Provider[ipNet] != nil {
-			ok, msg := deleteIP(ctx, ppfmt, c, s, ipNet)
-			allOk = allOk && ok
-			msgs[ok] = append(msgs[ok], msg...)
+			resps = append(resps, deleteIP(ctx, ppfmt, c, s, ipNet))
 		}
 	}
 
-	return allOk, strings.Join(msgs[allOk], "\n")
+	return response.Merge(resps...)
 }


Test patch:
diff --git a/internal/monitor/composite_test.go b/internal/monitor/composite_test.go
index 4f3ddda..50bd690 100644
--- a/internal/monitor/composite_test.go
+++ b/internal/monitor/composite_test.go
@@ -2,12 +2,14 @@ package monitor_test
 
 import (
 	"context"
+	"strings"
 	"testing"
 
 	"go.uber.org/mock/gomock"
 
 	"github.com/favonia/cloudflare-ddns/internal/mocks"
 	"github.com/favonia/cloudflare-ddns/internal/monitor"
+	"github.com/favonia/cloudflare-ddns/internal/response"
 )
 
 func TestDescribeAll(t *testing.T) {
@@ -43,7 +45,7 @@ func TestSuccessAll(t *testing.T) {
 		ms = append(ms, m)
 	}
 
-	monitor.SuccessAll(context.Background(), mockPP, message, ms)
+	monitor.SuccessAll(context.Background(), mockPP, ms, message)
 }
 
 func TestStartAll(t *testing.T) {
@@ -62,7 +64,7 @@ func TestStartAll(t *testing.T) {
 		ms = append(ms, m)
 	}
 
-	monitor.StartAll(context.Background(), mockPP, message, ms)
+	monitor.StartAll(context.Background(), mockPP, ms, message)
 }
 
 func TestFailureAll(t *testing.T) {
@@ -81,7 +83,7 @@ func TestFailureAll(t *testing.T) {
 		ms = append(ms, m)
 	}
 
-	monitor.FailureAll(context.Background(), mockPP, message, ms)
+	monitor.FailureAll(context.Background(), mockPP, ms, message)
 }
 
 func TestLogAll(t *testing.T) {
@@ -100,10 +102,10 @@ func TestLogAll(t *testing.T) {
 		ms = append(ms, m)
 	}
 
-	monitor.LogAll(context.Background(), mockPP, message, ms)
+	monitor.LogAll(context.Background(), mockPP, ms, message)
 }
 
-func TestMonitorsExitStatus(t *testing.T) {
+func TestExitStatusAll(t *testing.T) {
 	t.Parallel()
 
 	ms := make([]monitor.Monitor, 0, 5)
@@ -119,5 +121,45 @@ func TestMonitorsExitStatus(t *testing.T) {
 		ms = append(ms, m)
 	}
 
-	monitor.ExitStatusAll(context.Background(), mockPP, 42, message, ms)
+	monitor.ExitStatusAll(context.Background(), mockPP, ms, 42, message)
+}
+
+func TestSendAll(t *testing.T) {
+	t.Parallel()
+
+	monitorMessages := []string{"forest", "grass"}
+	monitorMessage := strings.Join(monitorMessages, "\n")
+	notifierMessages := []string{"ocean"}
+
+	for name, tc := range map[string]struct {
+		ok bool
+	}{
+		"ok":    {true},
+		"notok": {false},
+	} {
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			ms := make([]monitor.Monitor, 0, 5)
+			mockCtrl := gomock.NewController(t)
+			mockPP := mocks.NewMockPP(mockCtrl)
+
+			for range 5 {
+				m := mocks.NewMockMonitor(mockCtrl)
+				if tc.ok {
+					m.EXPECT().Log(context.Background(), mockPP, monitorMessage)
+				} else {
+					m.EXPECT().Failure(context.Background(), mockPP, monitorMessage)
+				}
+				ms = append(ms, m)
+			}
+
+			resp := response.Response{
+				Ok:               tc.ok,
+				MonitorMessages:  monitorMessages,
+				NotifierMessages: notifierMessages,
+			}
+			monitor.SendAll(context.Background(), mockPP, ms, resp)
+		})
+	}
 }
diff --git a/internal/setter/setter_test.go b/internal/setter/setter_test.go
index 821aea5..4b5a351 100644
--- a/internal/setter/setter_test.go
+++ b/internal/setter/setter_test.go
@@ -42,7 +42,7 @@ func TestSet(t *testing.T) {
 	}{
 		"0/1-false": {
 			ip1,
-			setter.ResponseUpdatesApplied,
+			setter.ResponseUpdated,
 			1,
 			false,
 			func(m *mocks.MockPP) {
@@ -57,7 +57,7 @@ func TestSet(t *testing.T) {
 		},
 		"1unmatched/300-false": {
 			ip1,
-			setter.ResponseUpdatesApplied,
+			setter.ResponseUpdated,
 			300,
 			false,
 			func(m *mocks.MockPP) {
@@ -77,7 +77,7 @@ func TestSet(t *testing.T) {
 		},
 		"1unmatched-updatefail/300-false": {
 			ip1,
-			setter.ResponseUpdatesApplied,
+			setter.ResponseUpdated,
 			300,
 			false,
 			func(m *mocks.MockPP) {
@@ -97,7 +97,7 @@ func TestSet(t *testing.T) {
 		},
 		"1matched/300-false": {
 			ip1,
-			setter.ResponseNoUpdatesNeeded,
+			setter.ResponseNoop,
 			300,
 			false,
 			func(m *mocks.MockPP) {
@@ -109,7 +109,7 @@ func TestSet(t *testing.T) {
 		},
 		"2matched/300-false": {
 			ip1,
-			setter.ResponseUpdatesApplied,
+			setter.ResponseUpdated,
 			300,
 			false,
 			func(m *mocks.MockPP) {
@@ -130,7 +130,7 @@ func TestSet(t *testing.T) {
 		},
 		"2matched-deletefail/300-false": {
 			ip1,
-			setter.ResponseUpdatesApplied,
+			setter.ResponseUpdated,
 			300,
 			false,
 			nil,
@@ -143,7 +143,7 @@ func TestSet(t *testing.T) {
 		},
 		"2unmatched/300-false": {
 			ip1,
-			setter.ResponseUpdatesApplied,
+			setter.ResponseUpdated,
 			300,
 			false,
 			func(m *mocks.MockPP) {
@@ -172,7 +172,7 @@ func TestSet(t *testing.T) {
 		},
 		"2unmatched-updatefail/300-false": {
 			ip1,
-			setter.ResponseUpdatesApplied,
+			setter.ResponseUpdated,
 			300,
 			false,
 			func(m *mocks.MockPP) {
@@ -198,7 +198,7 @@ func TestSet(t *testing.T) {
 		},
 		"2unmatched-updatefailtwice/300-false": {
 			ip1,
-			setter.ResponseUpdatesApplied,
+			setter.ResponseUpdated,
 			300,
 			false,
 			func(m *mocks.MockPP) {
@@ -222,14 +222,14 @@ func TestSet(t *testing.T) {
 		//nolint:dupl
 		"2unmatched-updatefail-deletefail-updatefail/300-false": {
 			ip1,
-			setter.ResponseUpdatesFailed,
+			setter.ResponseFailed,
 			300,
 			false,
 			func(m *mocks.MockPP) {
 				gomock.InOrder(
-					m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", "AAAA", "sub.test.org", record2),                      //nolint:lll
-					m.EXPECT().Noticef(pp.EmojiCreateRecord, "Added a new %s record of %q (ID: %q)", "AAAA", "sub.test.org", record3),                          //nolint:lll
-					m.EXPECT().Errorf(pp.EmojiError, "Failed to complete updating of %s records of %q; records might be inconsistent", "AAAA", "sub.test.org"), //nolint:lll
+					m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", "AAAA", "sub.test.org", record2),                 //nolint:lll
+					m.EXPECT().Noticef(pp.EmojiCreateRecord, "Added a new %s record of %q (ID: %q)", "AAAA", "sub.test.org", record3),                     //nolint:lll
+					m.EXPECT().Errorf(pp.EmojiError, "Failed to finish updating %s records of %q; records might be inconsistent", "AAAA", "sub.test.org"), //nolint:lll
 				)
 			},
 			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
@@ -246,14 +246,14 @@ func TestSet(t *testing.T) {
 		//nolint:dupl
 		"2unmatched-updatefailtwice-createfail/300-false": {
 			ip1,
-			setter.ResponseUpdatesFailed,
+			setter.ResponseFailed,
 			300,
 			false,
 			func(m *mocks.MockPP) {
 				gomock.InOrder(
-					m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", "AAAA", "sub.test.org", record1),                      //nolint:lll
-					m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", "AAAA", "sub.test.org", record2),                      //nolint:lll
-					m.EXPECT().Errorf(pp.EmojiError, "Failed to complete updating of %s records of %q; records might be inconsistent", "AAAA", "sub.test.org"), //nolint:lll
+					m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", "AAAA", "sub.test.org", record1),                 //nolint:lll
+					m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", "AAAA", "sub.test.org", record2),                 //nolint:lll
+					m.EXPECT().Errorf(pp.EmojiError, "Failed to finish updating %s records of %q; records might be inconsistent", "AAAA", "sub.test.org"), //nolint:lll
 				)
 			},
 			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
@@ -269,7 +269,7 @@ func TestSet(t *testing.T) {
 		},
 		"listfail/300-false": {
 			ip1,
-			setter.ResponseUpdatesFailed,
+			setter.ResponseFailed,
 			300,
 			false,
 			func(m *mocks.MockPP) {
@@ -326,7 +326,7 @@ func TestDelete(t *testing.T) {
 		prepareMockHandle func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle)
 	}{
 		"0": {
-			setter.ResponseNoUpdatesNeeded,
+			setter.ResponseNoop,
 			func(m *mocks.MockPP) {
 				m.EXPECT().Infof(pp.EmojiAlreadyDone, "The %s records of %q were already deleted", "AAAA", "sub.test.org")
 			},
@@ -335,7 +335,7 @@ func TestDelete(t *testing.T) {
 			},
 		},
 		"1unmatched": {
-			setter.ResponseUpdatesApplied,
+			setter.ResponseUpdated,
 			func(m *mocks.MockPP) {
 				m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", "AAAA", "sub.test.org", record1) //nolint:lll
 			},
@@ -347,9 +347,9 @@ func TestDelete(t *testing.T) {
 			},
 		},
 		"1unmatched/fail": {
-			setter.ResponseUpdatesFailed,
+			setter.ResponseFailed,
 			func(m *mocks.MockPP) {
-				m.EXPECT().Errorf(pp.EmojiError, "Failed to complete deleting of %s records of %q; records might be inconsistent", "AAAA", "sub.test.org") //nolint:lll
+				m.EXPECT().Errorf(pp.EmojiError, "Failed to finish deleting %s records of %q; records might be inconsistent", "AAAA", "sub.test.org") //nolint:lll
 			},
 			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
 				gomock.InOrder(
@@ -359,7 +359,7 @@ func TestDelete(t *testing.T) {
 			},
 		},
 		"impossible-records": {
-			setter.ResponseUpdatesApplied,
+			setter.ResponseUpdated,
 			func(m *mocks.MockPP) {
 				gomock.InOrder(
 					m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %q)", "AAAA", "sub.test.org", record1), //nolint:lll
@@ -375,7 +375,7 @@ func TestDelete(t *testing.T) {
 			},
 		},
 		"listfail": {
-			setter.ResponseUpdatesFailed,
+			setter.ResponseFailed,
 			func(m *mocks.MockPP) {
 				m.EXPECT().Errorf(pp.EmojiError, "Failed to retrieve the current %s records of %q", "AAAA", "sub.test.org")
 			},
diff --git a/internal/updater/response_test.go b/internal/updater/response_test.go
new file mode 100644
index 0000000..5b3e3dc
--- /dev/null
+++ b/internal/updater/response_test.go
@@ -0,0 +1,45 @@
+package updater_test
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/favonia/cloudflare-ddns/internal/updater"
+)
+
+func TestListJoin(t *testing.T) {
+	t.Parallel()
+	for name, tc := range map[string]struct {
+		input  []string
+		output string
+	}{
+		"none":  {nil, ""},
+		"one":   {[]string{"hello"}, "hello"},
+		"two":   {[]string{"hello", "hey"}, "hello, hey"},
+		"three": {[]string{"hello", "hey", "hi"}, "hello, hey, hi"},
+	} {
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+			require.Equal(t, tc.output, updater.ListJoin(tc.input))
+		})
+	}
+}
+
+func TestListEnglishJoin(t *testing.T) {
+	t.Parallel()
+	for name, tc := range map[string]struct {
+		input  []string
+		output string
+	}{
+		"none":  {nil, "(none)"},
+		"one":   {[]string{"hello"}, "hello"},
+		"two":   {[]string{"hello", "hey"}, "hello and hey"},
+		"three": {[]string{"hello", "hey", "hi"}, "hello, hey, and hi"},
+	} {
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+			require.Equal(t, tc.output, updater.ListEnglishJoin(tc.input))
+		})
+	}
+}
diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go
index b6f96ff..3b4ca9d 100644
--- a/internal/updater/updater_test.go
+++ b/internal/updater/updater_test.go
@@ -15,15 +15,381 @@ import (
 	"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/response"
 	"github.com/favonia/cloudflare-ddns/internal/setter"
 	"github.com/favonia/cloudflare-ddns/internal/updater"
 )
 
-//nolint:gochecknoglobals
-var allHints = map[string]bool{
-	"detect-ip4-fail": true,
-	"detect-ip6-fail": true,
-	"update-timeout":  true,
+//nolint:funlen,paralleltest // updater.IPv6MessageDisplayed is a global variable
+func TestUpdateIPsMultiple(t *testing.T) {
+	domain4_1 := domain.FQDN("ip4.hello1")
+	domain4_2 := domain.FQDN("ip4.hello2")
+	domain4_3 := domain.FQDN("ip4.hello3")
+	domain4_4 := domain.FQDN("ip4.hello4")
+	domains := map[ipnet.Type][]domain.Domain{
+		ipnet.IP4: {domain4_1, domain4_2, domain4_3, domain4_4},
+	}
+
+	ip4 := netip.MustParseAddr("127.0.0.1")
+
+	type mockproviders = map[ipnet.Type]func(ppfmt pp.PP, m *mocks.MockProvider)
+	provider4 := func(ppfmt pp.PP, m *mocks.MockProvider) {
+		m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP4, true).Return(ip4, true)
+	}
+
+	for name, tc := range map[string]struct {
+		ok                  bool
+		monitorMessages     []string
+		notifierMessages    []string
+		prepareMockPP       func(m *mocks.MockPP)
+		prepareMockProvider mockproviders
+		prepareMockSetter   func(ppfmt pp.PP, m *mocks.MockSetter)
+	}{
+		"2yes1no": { //nolint:dupl
+			false,
+			[]string{"Failed to set A (127.0.0.1): ip4.hello2"},
+			[]string{"Failed to finish updating A records of ip4.hello2 with 127.0.0.1; those of ip4.hello1 and ip4.hello4 were updated."}, //nolint:lll
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address: %v", "IPv4", ip4)
+			},
+			mockproviders{ipnet.IP4: provider4},
+			func(ppfmt pp.PP, m *mocks.MockSetter) {
+				gomock.InOrder(
+					m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello1"), ipnet.IP4, ip4, api.TTLAuto, false).
+						Return(setter.ResponseUpdated),
+					m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello2"), ipnet.IP4, ip4, api.TTLAuto, false).
+						Return(setter.ResponseFailed),
+					m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello3"), ipnet.IP4, ip4, api.TTLAuto, false).
+						Return(setter.ResponseNoop),
+					m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello4"), ipnet.IP4, ip4, api.TTLAuto, false).
+						Return(setter.ResponseUpdated),
+				)
+			},
+		},
+		"3yes": { //nolint:dupl
+			true,
+			[]string{"Set A (127.0.0.1): ip4.hello1, ip4.hello3, ip4.hello4"},
+			[]string{"Updated A records of ip4.hello1, ip4.hello3, and ip4.hello4 with 127.0.0.1."},
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address: %v", "IPv4", ip4)
+			},
+			mockproviders{ipnet.IP4: provider4},
+			func(ppfmt pp.PP, m *mocks.MockSetter) {
+				gomock.InOrder(
+					m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello1"), ipnet.IP4, ip4, api.TTLAuto, false).
+						Return(setter.ResponseUpdated),
+					m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello2"), ipnet.IP4, ip4, api.TTLAuto, false).
+						Return(setter.ResponseNoop),
+					m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello3"), ipnet.IP4, ip4, api.TTLAuto, false).
+						Return(setter.ResponseUpdated),
+					m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello4"), ipnet.IP4, ip4, api.TTLAuto, false).
+						Return(setter.ResponseUpdated),
+				)
+			},
+		},
+	} {
+		t.Run(name, func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+			ctx := context.Background()
+			conf := config.Default()
+			conf.Domains = domains
+			conf.TTL = api.TTLAuto
+			conf.Proxied = map[domain.Domain]bool{
+				domain4_1: false,
+				domain4_2: false,
+				domain4_3: false,
+				domain4_4: false,
+			}
+			conf.Use1001 = true
+			conf.UpdateTimeout = time.Second
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			for k := range updater.ShouldDisplayHints {
+				updater.ShouldDisplayHints[k] = true
+			}
+			for _, ipnet := range [...]ipnet.Type{ipnet.IP4, ipnet.IP6} {
+				if tc.prepareMockProvider[ipnet] == nil {
+					conf.Provider[ipnet] = nil
+					continue
+				}
+				mockProvider := mocks.NewMockProvider(mockCtrl)
+				tc.prepareMockProvider[ipnet](mockPP, mockProvider)
+				conf.Provider[ipnet] = mockProvider
+			}
+			mockSetter := mocks.NewMockSetter(mockCtrl)
+			if tc.prepareMockSetter != nil {
+				tc.prepareMockSetter(mockPP, mockSetter)
+			}
+			resp := updater.UpdateIPs(ctx, mockPP, conf, mockSetter)
+			require.Equal(t, response.Response{
+				Ok:               tc.ok,
+				NotifierMessages: tc.notifierMessages,
+				MonitorMessages:  tc.monitorMessages,
+			}, resp)
+		})
+	}
+}
+
+//nolint:funlen,paralleltest // updater.IPv6MessageDisplayed is a global variable
+func TestDeleteIPsMultiple(t *testing.T) {
+	domain4_1 := domain.FQDN("ip4.hello1")
+	domain4_2 := domain.FQDN("ip4.hello2")
+	domain4_3 := domain.FQDN("ip4.hello3")
+	domain4_4 := domain.FQDN("ip4.hello4")
+	domains := map[ipnet.Type][]domain.Domain{
+		ipnet.IP4: {domain4_1, domain4_2, domain4_3, domain4_4},
+	}
+
+	for name, tc := range map[string]struct {
+		ok                bool
+		monitorMessages   []string
+		notifierMessages  []string
+		prepareMockPP     func(m *mocks.MockPP)
+		prepareMockSetter func(ppfmt pp.PP, m *mocks.MockSetter)
+	}{
+		"2yes1no": {
+			false,
+			[]string{"Failed to delete A: ip4.hello2"},
+			[]string{"Failed to finish deleting A records of ip4.hello2; those of ip4.hello1 and ip4.hello4 were deleted."}, //nolint:lll
+			nil,
+			func(ppfmt pp.PP, m *mocks.MockSetter) {
+				gomock.InOrder(
+					m.EXPECT().Delete(gomock.Any(), ppfmt, domain.FQDN("ip4.hello1"), ipnet.IP4).
+						Return(setter.ResponseUpdated),
+					m.EXPECT().Delete(gomock.Any(), ppfmt, domain.FQDN("ip4.hello2"), ipnet.IP4).
+						Return(setter.ResponseFailed),
+					m.EXPECT().Delete(gomock.Any(), ppfmt, domain.FQDN("ip4.hello3"), ipnet.IP4).
+						Return(setter.ResponseNoop),
+					m.EXPECT().Delete(gomock.Any(), ppfmt, domain.FQDN("ip4.hello4"), ipnet.IP4).
+						Return(setter.ResponseUpdated),
+				)
+			},
+		},
+		"3yes": {
+			true,
+			[]string{"Deleted A: ip4.hello1, ip4.hello3, ip4.hello4"},
+			[]string{"Deleted A records of ip4.hello1, ip4.hello3, and ip4.hello4."},
+			nil,
+			func(ppfmt pp.PP, m *mocks.MockSetter) {
+				gomock.InOrder(
+					m.EXPECT().Delete(gomock.Any(), ppfmt, domain.FQDN("ip4.hello1"), ipnet.IP4).
+						Return(setter.ResponseUpdated),
+					m.EXPECT().Delete(gomock.Any(), ppfmt, domain.FQDN("ip4.hello2"), ipnet.IP4).
+						Return(setter.ResponseNoop),
+					m.EXPECT().Delete(gomock.Any(), ppfmt, domain.FQDN("ip4.hello3"), ipnet.IP4).
+						Return(setter.ResponseUpdated),
+					m.EXPECT().Delete(gomock.Any(), ppfmt, domain.FQDN("ip4.hello4"), ipnet.IP4).
+						Return(setter.ResponseUpdated),
+				)
+			},
+		},
+	} {
+		t.Run(name, func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+			ctx := context.Background()
+			conf := config.Default()
+			conf.Domains = domains
+			conf.TTL = api.TTLAuto
+			conf.Proxied = map[domain.Domain]bool{
+				domain4_1: false,
+				domain4_2: false,
+				domain4_3: false,
+				domain4_4: false,
+			}
+			conf.Use1001 = true
+			conf.UpdateTimeout = time.Second
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			for k := range updater.ShouldDisplayHints {
+				updater.ShouldDisplayHints[k] = true
+			}
+			for _, ipnet := range [...]ipnet.Type{ipnet.IP4, ipnet.IP6} {
+				conf.Provider[ipnet] = mocks.NewMockProvider(mockCtrl)
+			}
+			mockSetter := mocks.NewMockSetter(mockCtrl)
+			if tc.prepareMockSetter != nil {
+				tc.prepareMockSetter(mockPP, mockSetter)
+			}
+			resp := updater.DeleteIPs(ctx, mockPP, conf, mockSetter)
+			require.Equal(t, response.Response{
+				Ok:               tc.ok,
+				NotifierMessages: tc.notifierMessages,
+				MonitorMessages:  tc.monitorMessages,
+			}, resp)
+		})
+	}
+}
+
+//nolint:funlen,paralleltest // updater.IPv6MessageDisplayed is a global variable
+func TestUpdateIPsUninitializedProbied(t *testing.T) {
+	domain4 := domain.FQDN("ip4.hello")
+	domains := map[ipnet.Type][]domain.Domain{
+		ipnet.IP4: {domain4},
+	}
+
+	ip4 := netip.MustParseAddr("127.0.0.1")
+
+	type mockproviders = map[ipnet.Type]func(ppfmt pp.PP, m *mocks.MockProvider)
+	provider4 := func(ppfmt pp.PP, m *mocks.MockProvider) {
+		m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP4, true).Return(ip4, true)
+	}
+
+	for name, tc := range map[string]struct {
+		ok                  bool
+		monitorMessages     []string
+		notifierMessages    []string
+		prepareMockPP       func(m *mocks.MockPP)
+		prepareMockProvider mockproviders
+		prepareMockSetter   func(ppfmt pp.PP, m *mocks.MockSetter)
+	}{
+		"ip4only-proxied-nil": {
+			true,
+			[]string{"Set A (127.0.0.1): ip4.hello"},
+			[]string{"Updated A records of ip4.hello with 127.0.0.1."},
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address: %v", "IPv4", ip4),
+					m.EXPECT().Warningf(pp.EmojiImpossible,
+						"Proxied[%s] not initialized; this should not happen; please report the bug at https://github.com/favonia/cloudflare-ddns/issues/new", //nolint:lll
+						"ip4.hello",
+					),
+				)
+			},
+			mockproviders{ipnet.IP4: provider4},
+			func(ppfmt pp.PP, m *mocks.MockSetter) {
+				m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello"), ipnet.IP4, ip4, api.TTLAuto, false).
+					Return(setter.ResponseUpdated)
+			},
+		},
+	} {
+		t.Run(name, func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+			ctx := context.Background()
+			conf := config.Default()
+			conf.Domains = domains
+			conf.TTL = api.TTLAuto
+			conf.Proxied = map[domain.Domain]bool{}
+			conf.Use1001 = true
+			conf.UpdateTimeout = time.Second
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			for k := range updater.ShouldDisplayHints {
+				updater.ShouldDisplayHints[k] = true
+			}
+			for _, ipnet := range [...]ipnet.Type{ipnet.IP4, ipnet.IP6} {
+				if tc.prepareMockProvider[ipnet] == nil {
+					conf.Provider[ipnet] = nil
+					continue
+				}
+				mockProvider := mocks.NewMockProvider(mockCtrl)
+				tc.prepareMockProvider[ipnet](mockPP, mockProvider)
+				conf.Provider[ipnet] = mockProvider
+			}
+			mockSetter := mocks.NewMockSetter(mockCtrl)
+			if tc.prepareMockSetter != nil {
+				tc.prepareMockSetter(mockPP, mockSetter)
+			}
+			resp := updater.UpdateIPs(ctx, mockPP, conf, mockSetter)
+			require.Equal(t, response.Response{
+				Ok:               tc.ok,
+				NotifierMessages: tc.notifierMessages,
+				MonitorMessages:  tc.monitorMessages,
+			}, resp)
+		})
+	}
+}
+
+//nolint:funlen,paralleltest // updater.IPv6MessageDisplayed is a global variable
+func TestUpdateIPsHints(t *testing.T) {
+	domain4 := domain.FQDN("ip4.hello")
+	domain6 := domain.FQDN("ip6.hello")
+	domains := map[ipnet.Type][]domain.Domain{
+		ipnet.IP4: {domain4},
+		ipnet.IP6: {domain6},
+	}
+
+	ip4 := netip.MustParseAddr("127.0.0.1")
+
+	type mockproviders = map[ipnet.Type]func(ppfmt pp.PP, m *mocks.MockProvider)
+	provider4 := func(ppfmt pp.PP, m *mocks.MockProvider) {
+		m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP4, true).Return(ip4, true)
+	}
+
+	for name, tc := range map[string]struct {
+		ShouldDisplayHints  map[string]bool
+		ok                  bool
+		monitorMessages     []string
+		notifierMessages    []string
+		prepareMockPP       func(m *mocks.MockPP)
+		prepareMockProvider mockproviders
+		prepareMockSetter   func(ppfmt pp.PP, m *mocks.MockSetter)
+	}{
+		"ip6fails/again": {
+			map[string]bool{"detect-ip4-fail": true, "detect-ip6-fail": false, "update-timeout": true},
+			false,
+			[]string{"Failed to detect IPv6 address"},
+			[]string{"Failed to detect the IPv6 address."},
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address: %v", "IPv4", ip4),
+					m.EXPECT().Errorf(pp.EmojiError, "Failed to detect the %s address", "IPv6"),
+				)
+			},
+			mockproviders{
+				ipnet.IP4: provider4,
+				ipnet.IP6: func(ppfmt pp.PP, m *mocks.MockProvider) {
+					m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP6, true).Return(netip.Addr{}, false)
+				},
+			},
+			func(ppfmt pp.PP, m *mocks.MockSetter) {
+				m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello"), ipnet.IP4, ip4, api.TTLAuto, false).
+					Return(setter.ResponseNoop)
+			},
+		},
+	} {
+		t.Run(name, func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+			ctx := context.Background()
+			conf := config.Default()
+			conf.Domains = domains
+			conf.TTL = api.TTLAuto
+			conf.Proxied = map[domain.Domain]bool{domain4: false, domain6: false}
+			conf.Use1001 = true
+			conf.UpdateTimeout = time.Second
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			for k := range updater.ShouldDisplayHints {
+				updater.ShouldDisplayHints[k] = tc.ShouldDisplayHints[k]
+			}
+			for _, ipnet := range [...]ipnet.Type{ipnet.IP4, ipnet.IP6} {
+				if tc.prepareMockProvider[ipnet] == nil {
+					conf.Provider[ipnet] = nil
+					continue
+				}
+				mockProvider := mocks.NewMockProvider(mockCtrl)
+				tc.prepareMockProvider[ipnet](mockPP, mockProvider)
+				conf.Provider[ipnet] = mockProvider
+			}
+			mockSetter := mocks.NewMockSetter(mockCtrl)
+			if tc.prepareMockSetter != nil {
+				tc.prepareMockSetter(mockPP, mockSetter)
+			}
+			resp := updater.UpdateIPs(ctx, mockPP, conf, mockSetter)
+			require.Equal(t, response.Response{
+				Ok:               tc.ok,
+				NotifierMessages: tc.notifierMessages,
+				MonitorMessages:  tc.monitorMessages,
+			}, resp)
+		})
+	}
 }
 
 //nolint:funlen,paralleltest // updater.IPv6MessageDisplayed is a global variable
@@ -55,126 +421,113 @@ func TestUpdateIPs(t *testing.T) {
 		m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP6, true).Return(ip6, true)
 	}
 
-	type mockproxied = map[domain.Domain]bool
-	proxiedNone := mockproxied{domain4: false, domain6: false}
-	proxiedBoth := mockproxied{domain4: true, domain6: true}
-
 	for name, tc := range map[string]struct {
-		proxied             mockproxied
 		ok                  bool
-		msg                 string
-		ShouldDisplayHints  map[string]bool
+		monitorMessages     []string
+		notifierMessages    []string
 		prepareMockPP       func(m *mocks.MockPP)
 		prepareMockProvider mockproviders
 		prepareMockSetter   func(ppfmt pp.PP, m *mocks.MockSetter)
 	}{
 		"none": {
-			proxiedBoth, true, ``, allHints, nil, mockproviders{}, nil,
+			true, []string{}, []string{}, nil, mockproviders{}, nil,
 		},
 		"ip4only": {
-			proxiedNone,
 			true,
-			"",
-			allHints,
+			[]string{},
+			[]string{},
 			pp4only,
 			mockproviders{ipnet.IP4: provider4},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
 				m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello"), ipnet.IP4, ip4, api.TTLAuto, false).
-					Return(setter.ResponseNoUpdatesNeeded)
+					Return(setter.ResponseNoop)
 			},
 		},
 		"ip4only/setfail": {
-			proxiedBoth,
 			false,
-			"Failed to set ip4.hello A",
-			allHints,
+			[]string{"Failed to set A (127.0.0.1): ip4.hello"},
+			[]string{"Failed to finish updating A records of ip4.hello with 127.0.0.1."},
 			pp4only,
 			mockproviders{ipnet.IP4: provider4},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
-				m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello"), ipnet.IP4, ip4, api.TTLAuto, true).
-					Return(setter.ResponseUpdatesFailed)
+				m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello"), ipnet.IP4, ip4, api.TTLAuto, false).
+					Return(setter.ResponseFailed)
 			},
 		},
 		"ip6only": {
-			proxiedNone,
 			true,
-			"Set ip6.hello AAAA to ::1",
-			allHints,
+			[]string{"Set AAAA (::1): ip6.hello"},
+			[]string{"Updated AAAA records of ip6.hello with ::1."},
 			pp6only,
 			mockproviders{ipnet.IP6: provider6},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
 				m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip6.hello"), ipnet.IP6, ip6, api.TTLAuto, false).
-					Return(setter.ResponseUpdatesApplied)
+					Return(setter.ResponseUpdated)
 			},
 		},
 		"ip6only/setfail": {
-			proxiedBoth,
 			false,
-			"Failed to set ip6.hello AAAA",
-			allHints,
+			[]string{"Failed to set AAAA (::1): ip6.hello"},
+			[]string{"Failed to finish updating AAAA records of ip6.hello with ::1."},
 			pp6only,
 			mockproviders{ipnet.IP6: provider6},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
-				m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip6.hello"), ipnet.IP6, ip6, api.TTLAuto, true).
-					Return(setter.ResponseUpdatesFailed)
+				m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip6.hello"), ipnet.IP6, ip6, api.TTLAuto, false).
+					Return(setter.ResponseFailed)
 			},
 		},
 		"both": {
-			proxiedNone,
 			true,
-			"",
-			allHints,
+			[]string{},
+			[]string{},
 			ppBoth,
 			mockproviders{ipnet.IP4: provider4, ipnet.IP6: provider6},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
 				gomock.InOrder(
 					m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello"), ipnet.IP4, ip4, api.TTLAuto, false).
-						Return(setter.ResponseNoUpdatesNeeded),
+						Return(setter.ResponseNoop),
 					m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip6.hello"), ipnet.IP6, ip6, api.TTLAuto, false).
-						Return(setter.ResponseNoUpdatesNeeded),
+						Return(setter.ResponseNoop),
 				)
 			},
 		},
 		"both/setfail1": {
-			proxiedBoth,
 			false,
-			"Failed to set ip4.hello A",
-			allHints,
+			[]string{"Failed to set A (127.0.0.1): ip4.hello"},
+			[]string{"Failed to finish updating A records of ip4.hello with 127.0.0.1."},
 			ppBoth,
 			mockproviders{ipnet.IP4: provider4, ipnet.IP6: provider6},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
 				gomock.InOrder(
-					m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello"), ipnet.IP4, ip4, api.TTLAuto, true).
-						Return(setter.ResponseUpdatesFailed),
-					m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip6.hello"), ipnet.IP6, ip6, api.TTLAuto, true).
-						Return(setter.ResponseNoUpdatesNeeded),
+					m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello"), ipnet.IP4, ip4, api.TTLAuto, false).
+						Return(setter.ResponseFailed),
+					m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip6.hello"), ipnet.IP6, ip6, api.TTLAuto, false).
+						Return(setter.ResponseNoop),
 				)
 			},
 		},
 		"both/setfail2": {
-			proxiedNone,
 			false,
-			"Failed to set ip6.hello AAAA",
-			allHints,
+			[]string{"Failed to set AAAA (::1): ip6.hello"},
+			[]string{"Failed to finish updating AAAA records of ip6.hello with ::1."},
 			ppBoth,
 			mockproviders{ipnet.IP4: provider4, ipnet.IP6: provider6},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
 				gomock.InOrder(
 					m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello"), ipnet.IP4, ip4, api.TTLAuto, false).
-						Return(setter.ResponseNoUpdatesNeeded),
+						Return(setter.ResponseNoop),
 					m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip6.hello"), ipnet.IP6, ip6, api.TTLAuto, false).
-						Return(setter.ResponseUpdatesFailed),
+						Return(setter.ResponseFailed),
 				)
 			},
 		},
 		"ip4fails": {
-			proxiedBoth,
 			false,
-			"Failed to detect the IPv4 address",
-			allHints,
+			[]string{"Failed to detect IPv4 address"},
+			[]string{"Failed to detect the IPv4 address."},
 			func(m *mocks.MockPP) {
 				gomock.InOrder(
-					m.EXPECT().Errorf(pp.EmojiError, "%s", "Failed to detect the IPv4 address"),
+					m.EXPECT().Errorf(pp.EmojiError, "Failed to detect the %s address", "IPv4"),
 					m.EXPECT().Infof(pp.EmojiHint, "If your network does not support IPv4, you can disable it with IP4_PROVIDER=none"), //nolint:lll
 					m.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address: %v", "IPv6", ip6),
 				)
@@ -186,19 +539,18 @@ func TestUpdateIPs(t *testing.T) {
 				ipnet.IP6: provider6,
 			},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
-				m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip6.hello"), ipnet.IP6, ip6, api.TTLAuto, true).
-					Return(setter.ResponseNoUpdatesNeeded)
+				m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip6.hello"), ipnet.IP6, ip6, api.TTLAuto, false).
+					Return(setter.ResponseNoop)
 			},
 		},
 		"ip6fails": {
-			proxiedNone,
 			false,
-			"Failed to detect the IPv6 address",
-			allHints,
+			[]string{"Failed to detect IPv6 address"},
+			[]string{"Failed to detect the IPv6 address."},
 			func(m *mocks.MockPP) {
 				gomock.InOrder(
 					m.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address: %v", "IPv4", ip4),
-					m.EXPECT().Errorf(pp.EmojiError, "%s", "Failed to detect the IPv6 address"),
+					m.EXPECT().Errorf(pp.EmojiError, "Failed to detect the %s address", "IPv6"),
 					m.EXPECT().Infof(pp.EmojiHint, "If you are using Docker or Kubernetes, IPv6 often requires additional setups"),     //nolint:lll
 					m.EXPECT().Infof(pp.EmojiHint, "Read more about IPv6 networks at https://github.com/favonia/cloudflare-ddns"),      //nolint:lll
 					m.EXPECT().Infof(pp.EmojiHint, "If your network does not support IPv6, you can disable it with IP6_PROVIDER=none"), //nolint:lll
@@ -212,41 +564,18 @@ func TestUpdateIPs(t *testing.T) {
 			},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
 				m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello"), ipnet.IP4, ip4, api.TTLAuto, false).
-					Return(setter.ResponseNoUpdatesNeeded)
-			},
-		},
-		"ip6fails/again": {
-			proxiedBoth,
-			false,
-			"Failed to detect the IPv6 address",
-			map[string]bool{"detect-ip4-fail": true, "detect-ip6-fail": false, "update-timeout": true},
-			func(m *mocks.MockPP) {
-				gomock.InOrder(
-					m.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address: %v", "IPv4", ip4),
-					m.EXPECT().Errorf(pp.EmojiError, "%s", "Failed to detect the IPv6 address"),
-				)
-			},
-			mockproviders{
-				ipnet.IP4: provider4,
-				ipnet.IP6: func(ppfmt pp.PP, m *mocks.MockProvider) {
-					m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP6, true).Return(netip.Addr{}, false)
-				},
-			},
-			func(ppfmt pp.PP, m *mocks.MockSetter) {
-				m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello"), ipnet.IP4, ip4, api.TTLAuto, true).
-					Return(setter.ResponseNoUpdatesNeeded)
+					Return(setter.ResponseNoop)
 			},
 		},
 		"bothfail": {
-			proxiedNone,
 			false,
-			"Failed to detect the IPv4 address\nFailed to detect the IPv6 address",
-			allHints,
+			[]string{"Failed to detect IPv4 address", "Failed to detect IPv6 address"},
+			[]string{"Failed to detect the IPv4 address.", "Failed to detect the IPv6 address."},
 			func(m *mocks.MockPP) {
 				gomock.InOrder(
-					m.EXPECT().Errorf(pp.EmojiError, "%s", "Failed to detect the IPv4 address"),
+					m.EXPECT().Errorf(pp.EmojiError, "Failed to detect the %s address", "IPv4"),
 					m.EXPECT().Infof(pp.EmojiHint, "If your network does not support IPv4, you can disable it with IP4_PROVIDER=none"), //nolint:lll
-					m.EXPECT().Errorf(pp.EmojiError, "%s", "Failed to detect the IPv6 address"),
+					m.EXPECT().Errorf(pp.EmojiError, "Failed to detect the %s address", "IPv6"),
 					m.EXPECT().Infof(pp.EmojiHint, "If you are using Docker or Kubernetes, IPv6 often requires additional setups"),     //nolint:lll
 					m.EXPECT().Infof(pp.EmojiHint, "Read more about IPv6 networks at https://github.com/favonia/cloudflare-ddns"),      //nolint:lll
 					m.EXPECT().Infof(pp.EmojiHint, "If your network does not support IPv6, you can disable it with IP6_PROVIDER=none"), //nolint:lll
@@ -262,31 +591,10 @@ func TestUpdateIPs(t *testing.T) {
 			},
 			nil,
 		},
-		"ip4only-proxied-nil": {
-			mockproxied{},
-			true,
-			"Set ip4.hello A to 127.0.0.1",
-			allHints,
-			func(m *mocks.MockPP) {
-				gomock.InOrder(
-					m.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address: %v", "IPv4", ip4),
-					m.EXPECT().Warningf(pp.EmojiImpossible,
-						"Proxied[%s] not initialized; this should not happen; please report the bug at https://github.com/favonia/cloudflare-ddns/issues/new", //nolint:lll
-						"ip4.hello",
-					),
-				)
-			},
-			mockproviders{ipnet.IP4: provider4},
-			func(ppfmt pp.PP, m *mocks.MockSetter) {
-				m.EXPECT().Set(gomock.Any(), ppfmt, domain.FQDN("ip4.hello"), ipnet.IP4, ip4, api.TTLAuto, false).
-					Return(setter.ResponseUpdatesApplied)
-			},
-		},
 		"slow-setting": {
-			proxiedNone,
 			false,
-			"Failed to set ip4.hello A",
-			allHints,
+			[]string{"Failed to set A (127.0.0.1): ip4.hello"},
+			[]string{"Failed to finish updating A records of ip4.hello with 127.0.0.1."},
 			func(m *mocks.MockPP) {
 				gomock.InOrder(
 					m.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address: %v", "IPv4", ip4),
@@ -303,7 +611,7 @@ func TestUpdateIPs(t *testing.T) {
 					DoAndReturn(
 						func(_ context.Context, _ pp.PP, _ domain.Domain, _ ipnet.Type, _ netip.Addr, _ api.TTL, _ bool) setter.ResponseCode { //nolint:lll
 							time.Sleep(2 * time.Second)
-							return setter.ResponseUpdatesFailed
+							return setter.ResponseFailed
 						})
 			},
 		},
@@ -314,7 +622,7 @@ func TestUpdateIPs(t *testing.T) {
 			conf := config.Default()
 			conf.Domains = domains
 			conf.TTL = api.TTLAuto
-			conf.Proxied = tc.proxied
+			conf.Proxied = map[domain.Domain]bool{domain4: false, domain6: false}
 			conf.Use1001 = true
 			conf.UpdateTimeout = time.Second
 			mockPP := mocks.NewMockPP(mockCtrl)
@@ -322,7 +630,7 @@ func TestUpdateIPs(t *testing.T) {
 				tc.prepareMockPP(mockPP)
 			}
 			for k := range updater.ShouldDisplayHints {
-				updater.ShouldDisplayHints[k] = tc.ShouldDisplayHints[k]
+				updater.ShouldDisplayHints[k] = true
 			}
 			for _, ipnet := range [...]ipnet.Type{ipnet.IP4, ipnet.IP6} {
 				if tc.prepareMockProvider[ipnet] == nil {
@@ -337,9 +645,12 @@ func TestUpdateIPs(t *testing.T) {
 			if tc.prepareMockSetter != nil {
 				tc.prepareMockSetter(mockPP, mockSetter)
 			}
-			ok, msg := updater.UpdateIPs(ctx, mockPP, conf, mockSetter)
-			require.Equal(t, tc.ok, ok)
-			require.Equal(t, tc.msg, msg)
+			resp := updater.UpdateIPs(ctx, mockPP, conf, mockSetter)
+			require.Equal(t, response.Response{
+				Ok:               tc.ok,
+				NotifierMessages: tc.notifierMessages,
+				MonitorMessages:  tc.monitorMessages,
+			}, resp)
 		})
 	}
 }
@@ -355,111 +666,99 @@ func TestDeleteIPs(t *testing.T) {
 
 	type mockproviders = map[ipnet.Type]bool
 
-	type mockproxied = map[domain.Domain]bool
-	proxiedNone := mockproxied{domain4: false, domain6: false}
-
 	for name, tc := range map[string]struct {
-		proxied             mockproxied
 		ok                  bool
-		msg                 string
-		ShouldDisplayHints  map[string]bool
+		monitorMessages     []string
+		notifierMessages    []string
 		prepareMockPP       func(m *mocks.MockPP)
 		prepareMockProvider mockproviders
 		prepareMockSetter   func(ppfmt pp.PP, m *mocks.MockSetter)
 	}{
 		"none": {
-			proxiedNone,
 			true,
-			``,
-			allHints,
+			[]string{},
+			[]string{},
 			nil,
 			mockproviders{},
 			nil,
 		},
 		"ip4only": {
-			proxiedNone,
 			true,
-			"Deleted ip4.hello A",
-			allHints,
+			[]string{"Deleted A: ip4.hello"},
+			[]string{"Deleted A records of ip4.hello."},
 			nil,
 			mockproviders{ipnet.IP4: true},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
 				m.EXPECT().Delete(gomock.Any(), ppfmt, domain4, ipnet.IP4).
-					Return(setter.ResponseUpdatesApplied)
+					Return(setter.ResponseUpdated)
 			},
 		},
 		"ip4only/setfail": {
-			proxiedNone,
 			false,
-			"Failed to delete ip4.hello A",
-			allHints,
+			[]string{"Failed to delete A: ip4.hello"},
+			[]string{"Failed to finish deleting A records of ip4.hello."},
 			nil,
 			mockproviders{ipnet.IP4: true},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
-				m.EXPECT().Delete(gomock.Any(), ppfmt, domain4, ipnet.IP4).Return(setter.ResponseUpdatesFailed)
+				m.EXPECT().Delete(gomock.Any(), ppfmt, domain4, ipnet.IP4).Return(setter.ResponseFailed)
 			},
 		},
 		"ip6only": {
-			proxiedNone,
 			true,
-			"Deleted ip6.hello AAAA",
-			allHints,
+			[]string{"Deleted AAAA: ip6.hello"},
+			[]string{"Deleted AAAA records of ip6.hello."},
 			nil,
 			mockproviders{ipnet.IP6: true},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
-				m.EXPECT().Delete(gomock.Any(), ppfmt, domain6, ipnet.IP6).Return(setter.ResponseUpdatesApplied)
+				m.EXPECT().Delete(gomock.Any(), ppfmt, domain6, ipnet.IP6).Return(setter.ResponseUpdated)
 			},
 		},
 		"ip6only/setfail": {
-			proxiedNone,
 			false,
-			"Failed to delete ip6.hello AAAA",
-			allHints,
+			[]string{"Failed to delete AAAA: ip6.hello"},
+			[]string{"Failed to finish deleting AAAA records of ip6.hello."},
 			nil,
 			mockproviders{ipnet.IP6: true},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
-				m.EXPECT().Delete(gomock.Any(), ppfmt, domain6, ipnet.IP6).Return(setter.ResponseUpdatesFailed)
+				m.EXPECT().Delete(gomock.Any(), ppfmt, domain6, ipnet.IP6).Return(setter.ResponseFailed)
 			},
 		},
 		"both": {
-			proxiedNone,
 			true,
-			"Deleted ip4.hello A\nDeleted ip6.hello AAAA",
-			allHints,
+			[]string{"Deleted A: ip4.hello", "Deleted AAAA: ip6.hello"},
+			[]string{"Deleted A records of ip4.hello.", "Deleted AAAA records of ip6.hello."},
 			nil,
 			mockproviders{ipnet.IP4: true, ipnet.IP6: true},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
 				gomock.InOrder(
-					m.EXPECT().Delete(gomock.Any(), ppfmt, domain4, ipnet.IP4).Return(setter.ResponseUpdatesApplied),
-					m.EXPECT().Delete(gomock.Any(), ppfmt, domain6, ipnet.IP6).Return(setter.ResponseUpdatesApplied),
+					m.EXPECT().Delete(gomock.Any(), ppfmt, domain4, ipnet.IP4).Return(setter.ResponseUpdated),
+					m.EXPECT().Delete(gomock.Any(), ppfmt, domain6, ipnet.IP6).Return(setter.ResponseUpdated),
 				)
 			},
 		},
 		"both/setfail1": {
-			proxiedNone,
 			false,
-			"Failed to delete ip4.hello A",
-			allHints,
+			[]string{"Failed to delete A: ip4.hello"},
+			[]string{"Failed to finish deleting A records of ip4.hello."},
 			nil,
 			mockproviders{ipnet.IP4: true, ipnet.IP6: true},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
 				gomock.InOrder(
-					m.EXPECT().Delete(gomock.Any(), ppfmt, domain4, ipnet.IP4).Return(setter.ResponseUpdatesFailed),
-					m.EXPECT().Delete(gomock.Any(), ppfmt, domain6, ipnet.IP6).Return(setter.ResponseNoUpdatesNeeded),
+					m.EXPECT().Delete(gomock.Any(), ppfmt, domain4, ipnet.IP4).Return(setter.ResponseFailed),
+					m.EXPECT().Delete(gomock.Any(), ppfmt, domain6, ipnet.IP6).Return(setter.ResponseNoop),
 				)
 			},
 		},
 		"both/setfail2": {
-			proxiedNone,
 			false,
-			"Failed to delete ip6.hello AAAA",
-			allHints,
+			[]string{"Failed to delete AAAA: ip6.hello"},
+			[]string{"Failed to finish deleting AAAA records of ip6.hello."},
 			nil,
 			mockproviders{ipnet.IP4: true, ipnet.IP6: true},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
 				gomock.InOrder(
-					m.EXPECT().Delete(gomock.Any(), ppfmt, domain4, ipnet.IP4).Return(setter.ResponseNoUpdatesNeeded),
-					m.EXPECT().Delete(gomock.Any(), ppfmt, domain6, ipnet.IP6).Return(setter.ResponseUpdatesFailed),
+					m.EXPECT().Delete(gomock.Any(), ppfmt, domain4, ipnet.IP4).Return(setter.ResponseNoop),
+					m.EXPECT().Delete(gomock.Any(), ppfmt, domain6, ipnet.IP6).Return(setter.ResponseFailed),
 				)
 			},
 		},
@@ -470,13 +769,13 @@ func TestDeleteIPs(t *testing.T) {
 			conf := config.Default()
 			conf.Domains = domains
 			conf.TTL = api.TTLAuto
-			conf.Proxied = tc.proxied
+			conf.Proxied = map[domain.Domain]bool{domain4: false, domain6: false}
 			mockPP := mocks.NewMockPP(mockCtrl)
 			if tc.prepareMockPP != nil {
 				tc.prepareMockPP(mockPP)
 			}
 			for k := range updater.ShouldDisplayHints {
-				updater.ShouldDisplayHints[k] = tc.ShouldDisplayHints[k]
+				updater.ShouldDisplayHints[k] = true
 			}
 			for _, ipnet := range [...]ipnet.Type{ipnet.IP4, ipnet.IP6} {
 				if !tc.prepareMockProvider[ipnet] {
@@ -490,9 +789,13 @@ func TestDeleteIPs(t *testing.T) {
 			if tc.prepareMockSetter != nil {
 				tc.prepareMockSetter(mockPP, mockSetter)
 			}
-			ok, msg := updater.DeleteIPs(ctx, mockPP, conf, mockSetter)
-			require.Equal(t, tc.ok, ok)
-			require.Equal(t, tc.msg, msg)
+			resp := updater.DeleteIPs(ctx, mockPP, conf, mockSetter)
+
+			require.Equal(t, response.Response{
+				Ok:               tc.ok,
+				NotifierMessages: tc.notifierMessages,
+				MonitorMessages:  tc.monitorMessages,
+			}, resp)
 		})
 	}
 }