Repository: kopia/kopia
Pull request #4034 (2024-08-07 04:11:57)

feat(cli): support for defining notification profiles and templates via CLI
This only implements profile management, template management and sending functionality, but does not actually define templates nor use them.

Profile management:

```
$ kopia notification profile configure email \
    --profile-name=X \
    --smtp-server=smtp.gmail.com \
    --smtp-port=587 \
    --smtp-username=X \
    --smtp-password=X \
    --mail-from=X \
    --mail-to=X \
    [--format=html|txt]
    [--send-test-notification]

$ kopia notification profile configure pushover \
    --profile-name=X \
    --user-key=X \
    --app-token=X \
    [--format=html|txt]
    [--send-test-notification]

$ kopia notification profile configure webhook \
    --profile-name=X \
    --endpooint=http://some-address:port/path \
    --method=POST|PUT \
    [--format=html|txt]
    [--send-test-notification]

$ kopia notification profile test --profile-name=X

$ kopia notification profile delete --profile-name=X

$ kopia notification profile list
```

Template management

```
$ kopia notification template show X

$ kopia notification template set X \
   --from-stdin | --from-file=X | --editor

$ kopia notification template remove X

$ kopia notification template list

```

Implements https://github.com/kopia/kopia/issues/1958

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/kopia/kopia/pull/4034?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) Report
Attention: Patch coverage is `43.68932%` with `58 lines` in your changes missing coverage. Please review.
> Project coverage is 77.07%. Comparing base [(`cb455c6`)](https://app.codecov.io/gh/kopia/kopia/commit/cb455c699a04e54cb47e25a676d7d65063ce883e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) to head [(`c73462e`)](https://app.codecov.io/gh/kopia/kopia/commit/c73462ecb30b36e380c5ea9106f846c9aeb02b4d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).
> Report is 235 commits behind head on master.

| [Files](https://app.codecov.io/gh/kopia/kopia/pull/4034?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) | Patch % | Lines |
|---|---|---|
| [cli/command\_notification\_configure\_common.go](https://app.codecov.io/gh/kopia/kopia/pull/4034?src=pr&el=tree&filepath=cli%2Fcommand_notification_configure_common.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-Y2xpL2NvbW1hbmRfbm90aWZpY2F0aW9uX2NvbmZpZ3VyZV9jb21tb24uZ28=) | 12.50% | [27 Missing and 1 partial :warning: ](https://app.codecov.io/gh/kopia/kopia/pull/4034?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) |
| [cli/command\_notification\_list.go](https://app.codecov.io/gh/kopia/kopia/pull/4034?src=pr&el=tree&filepath=cli%2Fcommand_notification_list.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-Y2xpL2NvbW1hbmRfbm90aWZpY2F0aW9uX2xpc3QuZ28=) | 22.22% | [14 Missing :warning: ](https://app.codecov.io/gh/kopia/kopia/pull/4034?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) |
| [cli/command\_notification\_send.go](https://app.codecov.io/gh/kopia/kopia/pull/4034?src=pr&el=tree&filepath=cli%2Fcommand_notification_send.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-Y2xpL2NvbW1hbmRfbm90aWZpY2F0aW9uX3NlbmQuZ28=) | 23.52% | [13 Missing :warning: ](https://app.codecov.io/gh/kopia/kopia/pull/4034?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) |
| [cli/command\_notification\_delete.go](https://app.codecov.io/gh/kopia/kopia/pull/4034?src=pr&el=tree&filepath=cli%2Fcommand_notification_delete.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-Y2xpL2NvbW1hbmRfbm90aWZpY2F0aW9uX2RlbGV0ZS5nbw==) | 66.66% | [2 Missing :warning: ](https://app.codecov.io/gh/kopia/kopia/pull/4034?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) |
| [repo/manifest/manifest\_manager.go](https://app.codecov.io/gh/kopia/kopia/pull/4034?src=pr&el=tree&filepath=repo%2Fmanifest%2Fmanifest_manager.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-cmVwby9tYW5pZmVzdC9tYW5pZmVzdF9tYW5hZ2VyLmdv) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/kopia/kopia/pull/4034?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) |

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


```diff
@@            Coverage Diff             @@
##           master    #4034      +/-   ##
==========================================
+ Coverage   75.86%   77.07%   +1.20%     
==========================================
  Files         470      491      +21     
  Lines       37301    28953    -8348     
==========================================
- Hits        28299    22315    -5984     
+ Misses       7071     4727    -2344     
+ Partials     1931     1911      -20     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/kopia/kopia/pull/4034?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).   
: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=kopia).

julio-lopez: +1: LG 👍

Patch:
diff --git a/cli/app.go b/cli/app.go
index 37b004fa..17a49276 100644
--- a/cli/app.go
+++ b/cli/app.go
@@ -80,6 +80,7 @@ type appServices interface {
 	directRepositoryReadAction(act func(ctx context.Context, rep repo.DirectRepository) error) func(ctx *kingpin.ParseContext) error
 	repositoryReaderAction(act func(ctx context.Context, rep repo.Repository) error) func(ctx *kingpin.ParseContext) error
 	repositoryWriterAction(act func(ctx context.Context, rep repo.RepositoryWriter) error) func(ctx *kingpin.ParseContext) error
+	repositoryHintAction(act func(ctx context.Context, rep repo.Repository) []string) func() []string
 	maybeRepositoryAction(act func(ctx context.Context, rep repo.Repository) error, mode repositoryAccessMode) func(ctx *kingpin.ParseContext) error
 	baseActionWithContext(act func(ctx context.Context) error) func(ctx *kingpin.ParseContext) error
 	openRepository(ctx context.Context, mustBeConnected bool) (repo.Repository, error)
@@ -143,24 +144,25 @@ type App struct {
 	onFatalErrorCallbacks []func(err error)
 
 	// subcommands
-	blob        commandBlob
-	benchmark   commandBenchmark
-	cache       commandCache
-	content     commandContent
-	diff        commandDiff
-	index       commandIndex
-	list        commandList
-	server      commandServer
-	session     commandSession
-	policy      commandPolicy
-	restore     commandRestore
-	show        commandShow
-	snapshot    commandSnapshot
-	manifest    commandManifest
-	mount       commandMount
-	maintenance commandMaintenance
-	repository  commandRepository
-	logs        commandLogs
+	blob         commandBlob
+	benchmark    commandBenchmark
+	cache        commandCache
+	content      commandContent
+	diff         commandDiff
+	index        commandIndex
+	list         commandList
+	server       commandServer
+	session      commandSession
+	policy       commandPolicy
+	restore      commandRestore
+	show         commandShow
+	snapshot     commandSnapshot
+	manifest     commandManifest
+	mount        commandMount
+	maintenance  commandMaintenance
+	repository   commandRepository
+	logs         commandLogs
+	notification commandNotification
 
 	// testability hooks
 	testonlyIgnoreMissingRequiredFeatures bool
@@ -300,6 +302,7 @@ func (c *App) setup(app *kingpin.Application) {
 	c.index.setup(c, app)
 	c.list.setup(c, app)
 	c.logs.setup(c, app)
+	c.notification.setup(c, app)
 	c.server.setup(c, app)
 	c.session.setup(c, app)
 	c.restore.setup(c, app)
@@ -501,8 +504,15 @@ func (c *App) runAppWithContext(command *kingpin.CmdClause, cb func(ctx context.
 	}
 
 	err := func() error {
+		if command == nil {
+			defer c.runOnExit()
+
+			return cb(ctx)
+		}
+
 		tctx, span := tracer.Start(ctx, command.FullCommand(), trace.WithSpanKind(trace.SpanKindClient))
 		defer span.End()
+
 		defer c.runOnExit()
 
 		return cb(tctx)
@@ -570,6 +580,28 @@ func (c *App) maybeRepositoryAction(act func(ctx context.Context, rep repo.Repos
 	})
 }
 
+func (c *App) repositoryHintAction(act func(ctx context.Context, rep repo.Repository) []string) func() []string {
+	return func() []string {
+		var result []string
+
+		//nolint:errcheck
+		c.runAppWithContext(nil, func(ctx context.Context) error {
+			rep, err := c.openRepository(ctx, true)
+			if err != nil {
+				return nil
+			}
+
+			defer rep.Close(ctx) //nolint:errcheck
+
+			result = act(ctx, rep)
+
+			return nil
+		})
+
+		return result
+	}
+}
+
 func (c *App) maybeRunMaintenance(ctx context.Context, rep repo.Repository) error {
 	if !c.enableAutomaticMaintenance {
 		return nil
diff --git a/cli/command_notification.go b/cli/command_notification.go
new file mode 100644
index 00000000..910f9ab1
--- /dev/null
+++ b/cli/command_notification.go
@@ -0,0 +1,13 @@
+package cli
+
+type commandNotification struct {
+	profile  commandNotificationProfile
+	template commandNotificationTemplate
+}
+
+func (c *commandNotification) setup(svc appServices, parent commandParent) {
+	cmd := parent.Command("notification", "Notifications").Alias("notifications")
+
+	c.profile.setup(svc, cmd)
+	c.template.setup(svc, cmd)
+}
diff --git a/cli/command_notification_configure_common.go b/cli/command_notification_configure_common.go
new file mode 100644
index 00000000..24a860ad
--- /dev/null
+++ b/cli/command_notification_configure_common.go
@@ -0,0 +1,104 @@
+package cli
+
+import (
+	"context"
+
+	"golang.org/x/exp/maps"
+
+	"github.com/alecthomas/kingpin/v2"
+	"github.com/pkg/errors"
+
+	"github.com/kopia/kopia/notification"
+	"github.com/kopia/kopia/notification/notifyprofile"
+	"github.com/kopia/kopia/notification/sender"
+	"github.com/kopia/kopia/repo"
+)
+
+// commonNotificationOptions is a common configuration for notification methods.
+type commonNotificationOptions struct {
+	notificationProfileFlag
+	sendTestNotification bool
+	minSeverity          string
+}
+
+func (c *commonNotificationOptions) setup(svc appServices, cmd *kingpin.CmdClause) {
+	c.notificationProfileFlag.setup(svc, cmd)
+	cmd.Flag("send-test-notification", "Test the notification").BoolVar(&c.sendTestNotification)
+	cmd.Flag("min-severity", "Minimum severity").EnumVar(&c.minSeverity, maps.Keys(notification.SeverityToNumber)...)
+}
+
+// configureNotificationAction is a helper function that creates a Kingpin action that
+// configures a notification method.
+// it will read the existing profile, merge the provided options, and save the profile back
+// or send a test notification based on the flags.
+func configureNotificationAction[T comparable](
+	svc appServices,
+	c *commonNotificationOptions,
+	senderMethod sender.Method,
+	opt *T,
+	merge func(src T, dst *T, isUpdate bool),
+) func(ctx *kingpin.ParseContext) error {
+	return svc.directRepositoryWriteAction(func(ctx context.Context, rep repo.DirectRepositoryWriter) error {
+		var (
+			defaultT        T
+			previousOptions *T
+		)
+
+		// read the existing profile, if any.
+		oldProfile, exists, err := notifyprofile.GetProfile(ctx, rep, c.profileName)
+		if err != nil {
+			return errors.Wrap(err, "unable to get notification profile")
+		}
+
+		sev := notification.SeverityDefault
+
+		if exists {
+			if oldProfile.MethodConfig.Type != senderMethod {
+				return errors.Errorf("profile %q already exists but is not of type %q", c.profileName, senderMethod)
+			}
+
+			var parsedT T
+
+			if err := oldProfile.MethodConfig.Options(&parsedT); err != nil {
+				return errors.Wrapf(err, "profile %q already exists but is not of type %q", c.profileName, senderMethod)
+			}
+
+			previousOptions = &parsedT
+			sev = oldProfile.MinSeverity
+		} else {
+			previousOptions = &defaultT
+		}
+
+		if *opt != defaultT {
+			// any options provided on the command line, merge them with the existing ones.
+			merge(*opt, previousOptions, exists)
+		}
+
+		if c.minSeverity != "" {
+			// severity is specified on the command line, override the one from the profile.
+			sev = notification.SeverityToNumber[c.minSeverity]
+		}
+
+		s, err := sender.GetSender(ctx, c.profileName, senderMethod, previousOptions)
+		if err != nil {
+			return errors.Wrap(err, "unable to get notification provider")
+		}
+
+		if c.sendTestNotification {
+			if err := notification.SendTestNotification(ctx, rep, s); err != nil {
+				return errors.Wrap(err, "unable to send test notification")
+			}
+		}
+
+		log(ctx).Infof("Saving notification profile %q of type %q with severity %q.", c.profileName, senderMethod, notification.SeverityToString[sev])
+
+		return notifyprofile.SaveProfile(ctx, rep, notifyprofile.Config{
+			ProfileName: c.profileName,
+			MethodConfig: sender.MethodConfig{
+				Type:   senderMethod,
+				Config: previousOptions,
+			},
+			MinSeverity: sev,
+		})
+	})
+}
diff --git a/cli/command_notification_configure_email.go b/cli/command_notification_configure_email.go
new file mode 100644
index 00000000..003d9963
--- /dev/null
+++ b/cli/command_notification_configure_email.go
@@ -0,0 +1,31 @@
+package cli
+
+import (
+	"github.com/kopia/kopia/notification/sender"
+	"github.com/kopia/kopia/notification/sender/email"
+)
+
+type commandNotificationConfigureEmail struct {
+	common commonNotificationOptions
+
+	opt email.Options
+}
+
+func (c *commandNotificationConfigureEmail) setup(svc appServices, parent commandParent) {
+	cmd := parent.Command("email", "E-mail notification.")
+
+	c.common.setup(svc, cmd)
+
+	cmd.Flag("smtp-server", "SMTP server").StringVar(&c.opt.SMTPServer)
+	cmd.Flag("smtp-port", "SMTP port").IntVar(&c.opt.SMTPPort)
+	cmd.Flag("smtp-identity", "SMTP identity").StringVar(&c.opt.SMTPIdentity)
+	cmd.Flag("smtp-username", "SMTP username").StringVar(&c.opt.SMTPUsername)
+	cmd.Flag("smtp-password", "SMTP password").StringVar(&c.opt.SMTPPassword)
+	cmd.Flag("mail-from", "From address").StringVar(&c.opt.From)
+	cmd.Flag("mail-to", "To address").StringVar(&c.opt.To)
+	cmd.Flag("mail-cc", "CC address").StringVar(&c.opt.CC)
+
+	cmd.Flag("format", "Format of the message").EnumVar(&c.opt.Format, sender.FormatHTML, sender.FormatPlainText)
+
+	cmd.Action(configureNotificationAction(svc, &c.common, email.ProviderType, &c.opt, email.MergeOptions))
+}
diff --git a/cli/command_notification_configure_pushover.go b/cli/command_notification_configure_pushover.go
new file mode 100644
index 00000000..0f6d60b0
--- /dev/null
+++ b/cli/command_notification_configure_pushover.go
@@ -0,0 +1,24 @@
+package cli
+
+import (
+	"github.com/kopia/kopia/notification/sender"
+	"github.com/kopia/kopia/notification/sender/pushover"
+)
+
+type commandNotificationConfigurePushover struct {
+	common commonNotificationOptions
+
+	opt pushover.Options
+}
+
+func (c *commandNotificationConfigurePushover) setup(svc appServices, parent commandParent) {
+	cmd := parent.Command("pushover", "Pushover notification.")
+
+	c.common.setup(svc, cmd)
+
+	cmd.Flag("app-token", "Pushover App Token").StringVar(&c.opt.AppToken)
+	cmd.Flag("user-key", "Pushover User Key").StringVar(&c.opt.UserKey)
+	cmd.Flag("format", "Format of the message").EnumVar(&c.opt.Format, sender.FormatHTML, sender.FormatPlainText)
+
+	cmd.Action(configureNotificationAction(svc, &c.common, pushover.ProviderType, &c.opt, pushover.MergeOptions))
+}
diff --git a/cli/command_notification_configure_webhook.go b/cli/command_notification_configure_webhook.go
new file mode 100644
index 00000000..0dd52fbf
--- /dev/null
+++ b/cli/command_notification_configure_webhook.go
@@ -0,0 +1,26 @@
+package cli
+
+import (
+	"net/http"
+
+	"github.com/kopia/kopia/notification/sender"
+	"github.com/kopia/kopia/notification/sender/webhook"
+)
+
+type commandNotificationConfigureWebhook struct {
+	common commonNotificationOptions
+
+	opt webhook.Options
+}
+
+func (c *commandNotificationConfigureWebhook) setup(svc appServices, parent commandParent) {
+	cmd := parent.Command("webhook", "Webhook notification.")
+
+	c.common.setup(svc, cmd)
+
+	cmd.Flag("endpoint", "SMTP server").StringVar(&c.opt.Endpoint)
+	cmd.Flag("method", "HTTP Method").EnumVar(&c.opt.Method, http.MethodPost, http.MethodPut)
+	cmd.Flag("format", "Format of the message").EnumVar(&c.opt.Format, sender.FormatHTML, sender.FormatPlainText)
+
+	cmd.Action(configureNotificationAction(svc, &c.common, webhook.ProviderType, &c.opt, webhook.MergeOptions))
+}
diff --git a/cli/command_notification_profile.go b/cli/command_notification_profile.go
new file mode 100644
index 00000000..767b4150
--- /dev/null
+++ b/cli/command_notification_profile.go
@@ -0,0 +1,51 @@
+package cli
+
+import (
+	"context"
+	"strings"
+
+	"github.com/alecthomas/kingpin/v2"
+
+	"github.com/kopia/kopia/notification/notifyprofile"
+	"github.com/kopia/kopia/repo"
+)
+
+type commandNotificationProfile struct {
+	config commandNotificationProfileConfigure
+	list   commandNotificationProfileList
+	delete commandNotificationProfileDelete
+	test   commandNotificationProfileTest
+}
+
+func (c *commandNotificationProfile) setup(svc appServices, parent commandParent) {
+	cmd := parent.Command("profile", "Manage notification profiles")
+	c.config.setup(svc, cmd)
+	c.delete.setup(svc, cmd)
+	c.test.setup(svc, cmd)
+	c.list.setup(svc, cmd)
+}
+
+type notificationProfileFlag struct {
+	profileName string
+}
+
+func (c *notificationProfileFlag) setup(svc appServices, cmd *kingpin.CmdClause) {
+	cmd.Flag("profile-name", "Profile name").Required().HintAction(svc.repositoryHintAction(c.listNotificationProfiles)).StringVar(&c.profileName)
+}
+
+func (c *notificationProfileFlag) listNotificationProfiles(ctx context.Context, rep repo.Repository) []string {
+	profiles, err := notifyprofile.ListProfiles(ctx, rep)
+	if err != nil {
+		return nil
+	}
+
+	var hints []string
+
+	for _, ti := range profiles {
+		if strings.HasPrefix(ti.ProfileName, c.profileName) {
+			hints = append(hints, ti.ProfileName)
+		}
+	}
+
+	return hints
+}
diff --git a/cli/command_notification_profile_configure.go b/cli/command_notification_profile_configure.go
new file mode 100644
index 00000000..f7f99188
--- /dev/null
+++ b/cli/command_notification_profile_configure.go
@@ -0,0 +1,19 @@
+package cli
+
+type commandNotificationProfileConfigure struct {
+	commandNotificationConfigureEmail
+	commandNotificationConfigurePushover
+	commandNotificationConfigureWebhook
+	commandNotificationConfigureTestSender
+}
+
+func (c *commandNotificationProfileConfigure) setup(svc appServices, parent commandParent) {
+	cmd := parent.Command("configure", "Setup notifications").Alias("setup")
+	c.commandNotificationConfigureEmail.setup(svc, cmd)
+	c.commandNotificationConfigurePushover.setup(svc, cmd)
+	c.commandNotificationConfigureWebhook.setup(svc, cmd)
+
+	if svc.enableTestOnlyFlags() {
+		c.commandNotificationConfigureTestSender.setup(svc, cmd)
+	}
+}
diff --git a/cli/command_notification_profile_delete.go b/cli/command_notification_profile_delete.go
new file mode 100644
index 00000000..74989571
--- /dev/null
+++ b/cli/command_notification_profile_delete.go
@@ -0,0 +1,25 @@
+package cli
+
+import (
+	"context"
+
+	"github.com/kopia/kopia/notification/notifyprofile"
+	"github.com/kopia/kopia/repo"
+)
+
+type commandNotificationProfileDelete struct {
+	notificationProfileFlag
+}
+
+func (c *commandNotificationProfileDelete) setup(svc appServices, parent commandParent) {
+	cmd := parent.Command("delete", "Delete notification profile").Alias("rm")
+
+	c.notificationProfileFlag.setup(svc, cmd)
+
+	cmd.Action(svc.repositoryWriterAction(c.run))
+}
+
+func (c *commandNotificationProfileDelete) run(ctx context.Context, rep repo.RepositoryWriter) error {
+	//nolint:wrapcheck
+	return notifyprofile.DeleteProfile(ctx, rep, c.profileName)
+}
diff --git a/cli/command_notification_profile_list.go b/cli/command_notification_profile_list.go
new file mode 100644
index 00000000..e445f4f2
--- /dev/null
+++ b/cli/command_notification_profile_list.go
@@ -0,0 +1,72 @@
+package cli
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/pkg/errors"
+
+	"github.com/kopia/kopia/notification"
+	"github.com/kopia/kopia/notification/notifyprofile"
+	"github.com/kopia/kopia/notification/sender"
+	"github.com/kopia/kopia/repo"
+)
+
+type commandNotificationProfileList struct {
+	out textOutput
+	jo  jsonOutput
+}
+
+func (c *commandNotificationProfileList) setup(svc appServices, parent commandParent) {
+	cmd := parent.Command("list", "List notification profiles").Alias("ls")
+
+	c.out.setup(svc)
+	c.jo.setup(svc, cmd)
+
+	cmd.Action(svc.repositoryReaderAction(c.run))
+}
+
+func (c *commandNotificationProfileList) run(ctx context.Context, rep repo.Repository) error {
+	var jl jsonList
+
+	if c.jo.jsonOutput {
+		jl.begin(&c.jo)
+		defer jl.end()
+	}
+
+	profileConfigs, err := notifyprofile.ListProfiles(ctx, rep)
+	if err != nil {
+		return errors.Wrap(err, "unable to list notification profiles")
+	}
+
+	for i, pc := range profileConfigs {
+		var summ notifyprofile.Summary
+
+		summ.ProfileName = pc.ProfileName
+		summ.Type = string(pc.MethodConfig.Type)
+		summ.MinSeverity = int32(pc.MinSeverity)
+
+		// Provider returns a new instance of the notification provider.
+		if prov, err := sender.GetSender(ctx, pc.ProfileName, pc.MethodConfig.Type, pc.MethodConfig.Config); err == nil {
+			summ.Summary = prov.Summary()
+		} else {
+			summ.Summary = fmt.Sprintf("%v - invalid", pc.MethodConfig.Type)
+		}
+
+		if c.jo.jsonOutput {
+			jl.emit(summ)
+		} else {
+			if i > 0 {
+				c.out.printStdout("\n")
+			}
+
+			c.out.printStdout("Profile %q Type %q Minimum Severity: %v\n  %v\n",
+				summ.ProfileName,
+				pc.MethodConfig.Type,
+				notification.SeverityToString[pc.MinSeverity],
+				summ.Summary)
+		}
+	}
+
+	return nil
+}
diff --git a/cli/command_notification_profile_send.go b/cli/command_notification_profile_send.go
new file mode 100644
index 00000000..d1e808b6
--- /dev/null
+++ b/cli/command_notification_profile_send.go
@@ -0,0 +1,42 @@
+package cli
+
+import (
+	"context"
+
+	"github.com/pkg/errors"
+
+	"github.com/kopia/kopia/notification"
+	"github.com/kopia/kopia/notification/notifyprofile"
+	"github.com/kopia/kopia/notification/sender"
+	"github.com/kopia/kopia/repo"
+)
+
+type commandNotificationProfileTest struct {
+	notificationProfileFlag
+}
+
+func (c *commandNotificationProfileTest) setup(svc appServices, parent commandParent) {
+	cmd := parent.Command("test", "Send test notification").Alias("send-test-message")
+
+	c.notificationProfileFlag.setup(svc, cmd)
+
+	cmd.Action(svc.repositoryReaderAction(c.run))
+}
+
+func (c *commandNotificationProfileTest) run(ctx context.Context, rep repo.Repository) error {
+	p, ok, err := notifyprofile.GetProfile(ctx, rep, c.profileName)
+	if err != nil {
+		return errors.Wrap(err, "unable to get notification profile")
+	}
+
+	if !ok {
+		return errors.Errorf("notification profile %q not found", c.profileName)
+	}
+
+	snd, err := sender.GetSender(ctx, p.ProfileName, p.MethodConfig.Type, p.MethodConfig.Config)
+	if err != nil {
+		return errors.Wrap(err, "unable to get notification sender")
+	}
+
+	return notification.SendTestNotification(ctx, rep, snd) //nolint:wrapcheck
+}
diff --git a/cli/command_notification_template.go b/cli/command_notification_template.go
new file mode 100644
index 00000000..f94fda4b
--- /dev/null
+++ b/cli/command_notification_template.go
@@ -0,0 +1,45 @@
+package cli
+
+import (
+	"context"
+
+	"github.com/alecthomas/kingpin/v2"
+
+	"github.com/kopia/kopia/notification/notifytemplate"
+	"github.com/kopia/kopia/repo"
+)
+
+type commandNotificationTemplate struct {
+	list   commandNotificationTemplateList
+	show   commandNotificationTemplateShow
+	set    commandNotificationTemplateSet
+	remove commandNotificationTemplateRemove
+}
+
+type notificationTemplateNameArg struct {
+	templateName string
+}
+
+func (c *notificationTemplateNameArg) setup(svc appServices, cmd *kingpin.CmdClause) {
+	cmd.Arg("template", "Template name").Required().HintAction(svc.repositoryHintAction(c.listNotificationTemplates)).StringVar(&c.templateName)
+}
+
+func (c *notificationTemplateNameArg) listNotificationTemplates(ctx context.Context, rep repo.Repository) []string {
+	infos, _ := notifytemplate.ListTemplates(ctx, rep, c.templateName)
+
+	var hints []string
+
+	for _, ti := range infos {
+		hints = append(hints, ti.Name)
+	}
+
+	return hints
+}
+
+func (c *commandNotificationTemplate) setup(svc appServices, parent commandParent) {
+	cmd := parent.Command("template", "Manage templates")
+	c.list.setup(svc, cmd)
+	c.set.setup(svc, cmd)
+	c.show.setup(svc, cmd)
+	c.remove.setup(svc, cmd)
+}
diff --git a/cli/command_notification_template_list.go b/cli/command_notification_template_list.go
new file mode 100644
index 00000000..8170acac
--- /dev/null
+++ b/cli/command_notification_template_list.go
@@ -0,0 +1,65 @@
+package cli
+
+import (
+	"context"
+	"sort"
+
+	"github.com/pkg/errors"
+
+	"github.com/kopia/kopia/notification/notifytemplate"
+	"github.com/kopia/kopia/repo"
+)
+
+type commandNotificationTemplateList struct {
+	out textOutput
+	jo  jsonOutput
+}
+
+func (c *commandNotificationTemplateList) setup(svc appServices, parent commandParent) {
+	cmd := parent.Command("list", "List templates")
+	cmd.Action(svc.repositoryReaderAction(c.run))
+
+	c.out.setup(svc)
+	c.jo.setup(svc, cmd)
+}
+
+func (c *commandNotificationTemplateList) run(ctx context.Context, rep repo.Repository) error {
+	infos, err := notifytemplate.ListTemplates(ctx, rep, "")
+	if err != nil {
+		return errors.Wrap(err, "error listing templates")
+	}
+
+	sort.Slice(infos, func(i, j int) bool {
+		return infos[i].Name < infos[j].Name
+	})
+
+	var jl jsonList
+
+	if c.jo.jsonOutput {
+		jl.begin(&c.jo)
+		defer jl.end()
+	}
+
+	c.out.printStdout("%-30v %-15v %v\n", "NAME", "TYPE", "MODIFIED")
+
+	for _, i := range infos {
+		if c.jo.jsonOutput {
+			jl.emit(i)
+			continue
+		}
+
+		var typeString, lastModString string
+
+		if i.LastModified == nil {
+			typeString = "<built-in>"
+			lastModString = ""
+		} else {
+			typeString = "<customized>"
+			lastModString = formatTimestamp(*i.LastModified)
+		}
+
+		c.out.printStdout("%-30v %-15v %v\n", i.Name, typeString, lastModString)
+	}
+
+	return nil
+}
diff --git a/cli/command_notification_template_remove.go b/cli/command_notification_template_remove.go
new file mode 100644
index 00000000..ff9850b9
--- /dev/null
+++ b/cli/command_notification_template_remove.go
@@ -0,0 +1,23 @@
+package cli
+
+import (
+	"context"
+
+	"github.com/kopia/kopia/notification/notifytemplate"
+	"github.com/kopia/kopia/repo"
+)
+
+type commandNotificationTemplateRemove struct {
+	notificationTemplateNameArg
+}
+
+func (c *commandNotificationTemplateRemove) setup(svc appServices, parent commandParent) {
+	cmd := parent.Command("remove", "Remove the notification template").Alias("rm").Alias("delete")
+	c.notificationTemplateNameArg.setup(svc, cmd)
+	cmd.Action(svc.repositoryWriterAction(c.run))
+}
+
+func (c *commandNotificationTemplateRemove) run(ctx context.Context, rep repo.RepositoryWriter) error {
+	//nolint:wrapcheck
+	return notifytemplate.ResetTemplate(ctx, rep, c.templateName)
+}
diff --git a/cli/command_notification_template_set.go b/cli/command_notification_template_set.go
new file mode 100644
index 00000000..82d5b6b1
--- /dev/null
+++ b/cli/command_notification_template_set.go
@@ -0,0 +1,93 @@
+package cli
+
+import (
+	"context"
+	"io"
+	"os"
+
+	"github.com/pkg/errors"
+
+	"github.com/kopia/kopia/internal/editor"
+	"github.com/kopia/kopia/notification/notifytemplate"
+	"github.com/kopia/kopia/repo"
+)
+
+type commandNotificationTemplateSet struct {
+	notificationTemplateNameArg
+	fromStdin    bool
+	fromFileName string
+	editor       bool
+
+	out textOutput
+	svc appServices
+}
+
+func (c *commandNotificationTemplateSet) setup(svc appServices, parent commandParent) {
+	cmd := parent.Command("set", "Set the notification template")
+
+	c.notificationTemplateNameArg.setup(svc, cmd)
+
+	cmd.Flag("from-stdin", "Read new template from stdin").BoolVar(&c.fromStdin)
+	cmd.Flag("from-file", "Read new template from file").ExistingFileVar(&c.fromFileName)
+	cmd.Flag("editor", "Edit template using default editor").BoolVar(&c.editor)
+	cmd.Action(svc.repositoryWriterAction(c.run))
+
+	c.svc = svc
+	c.out.setup(svc)
+}
+
+func (c *commandNotificationTemplateSet) run(ctx context.Context, rep repo.RepositoryWriter) error {
+	var (
+		data []byte
+		err  error
+	)
+
+	switch {
+	case c.fromStdin:
+		data, err = io.ReadAll(c.svc.stdin())
+	case c.fromFileName != "":
+		data, err = os.ReadFile(c.fromFileName)
+	case c.editor:
+		return c.launchEditor(ctx, rep)
+	default:
+		return errors.Errorf("must specify either --from-file, --from-stdin or --editor")
+	}
+
+	if err != nil {
+		return errors.Wrap(err, "error reading template")
+	}
+
+	//nolint:wrapcheck
+	return notifytemplate.SetTemplate(ctx, rep, c.templateName, string(data))
+}
+
+func (c *commandNotificationTemplateSet) launchEditor(ctx context.Context, rep repo.RepositoryWriter) error {
+	s, found, err := notifytemplate.GetTemplate(ctx, rep, c.templateName)
+	if err != nil {
+		return errors.Wrap(err, "unable to get template")
+	}
+
+	if !found {
+		s, err = notifytemplate.GetEmbeddedTemplate(c.templateName)
+		if err != nil {
+			return errors.Wrap(err, "unable to get template")
+		}
+	}
+
+	var lastUpdated string
+
+	if err := editor.EditLoop(ctx, "template.md", s, false, func(updated string) error {
+		_, err := notifytemplate.ParseTemplate(updated)
+		if err == nil {
+			lastUpdated = updated
+			return nil
+		}
+
+		return errors.Wrap(err, "invalid template")
+	}); err != nil {
+		return errors.Wrap(err, "unable to edit template")
+	}
+
+	//nolint:wrapcheck
+	return notifytemplate.SetTemplate(ctx, rep, c.templateName, lastUpdated)
+}
diff --git a/cli/command_notification_template_show.go b/cli/command_notification_template_show.go
new file mode 100644
index 00000000..f9f31819
--- /dev/null
+++ b/cli/command_notification_template_show.go
@@ -0,0 +1,73 @@
+package cli
+
+import (
+	"context"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/pkg/errors"
+	"github.com/skratchdot/open-golang/open"
+
+	"github.com/kopia/kopia/notification/notifytemplate"
+	"github.com/kopia/kopia/repo"
+)
+
+type commandNotificationTemplateShow struct {
+	notificationTemplateNameArg
+
+	templateFormat string
+	original       bool
+	htmlOutput     bool
+
+	out textOutput
+}
+
+func (c *commandNotificationTemplateShow) setup(svc appServices, parent commandParent) {
+	cmd := parent.Command("show", "Show template")
+	c.notificationTemplateNameArg.setup(svc, cmd)
+
+	cmd.Flag("format", "Template format").EnumVar(&c.templateFormat, "html", "md")
+	cmd.Flag("original", "Show original template").BoolVar(&c.original)
+	cmd.Flag("html", "Convert the output to HTML").BoolVar(&c.htmlOutput)
+	cmd.Action(svc.repositoryReaderAction(c.run))
+
+	c.out.setup(svc)
+}
+
+func (c *commandNotificationTemplateShow) run(ctx context.Context, rep repo.Repository) error {
+	var (
+		text string
+		err  error
+	)
+
+	if c.original {
+		text, err = notifytemplate.GetEmbeddedTemplate(c.templateName)
+	} else {
+		var found bool
+
+		text, found, err = notifytemplate.GetTemplate(ctx, rep, c.templateName)
+		if !found {
+			text, err = notifytemplate.GetEmbeddedTemplate(c.templateName)
+		}
+	}
+
+	if err != nil {
+		return errors.Wrap(err, "error listing templates")
+	}
+
+	if c.htmlOutput {
+		tf := filepath.Join(os.TempDir(), "kopia-template-preview.html")
+
+		//nolint:gosec,mnd
+		if err := os.WriteFile(tf, []byte(text), 0o644); err != nil {
+			return errors.Wrap(err, "error writing template to file")
+		}
+
+		open.Run(tf) //nolint:errcheck
+	}
+
+	c.out.printStdout("%v\n", strings.TrimRight(text, "\n"))
+
+	return nil
+}
diff --git a/cli/command_policy_edit.go b/cli/command_policy_edit.go
index f47d3cc5..20562156 100644
--- a/cli/command_policy_edit.go
+++ b/cli/command_policy_edit.go
@@ -84,7 +84,7 @@ func (c *commandPolicyEdit) run(ctx context.Context, rep repo.RepositoryWriter)
 
 		var updated *policy.Policy
 
-		if err := editor.EditLoop(ctx, "policy.conf", s, func(edited string) error {
+		if err := editor.EditLoop(ctx, "policy.conf", s, true, func(edited string) error {
 			updated = &policy.Policy{}
 			d := json.NewDecoder(bytes.NewBufferString(edited))
 			d.DisallowUnknownFields()
diff --git a/go.mod b/go.mod
index 0f7e600e..586bd89b 100644
--- a/go.mod
+++ b/go.mod
@@ -32,6 +32,7 @@ require (
 	github.com/kylelemons/godebug v1.1.0
 	github.com/mattn/go-colorable v0.1.13
 	github.com/minio/minio-go/v7 v7.0.77
+	github.com/mocktools/go-smtp-mock/v2 v2.3.1
 	github.com/mxk/go-vss v1.2.0
 	github.com/natefinch/atomic v1.0.1
 	github.com/pierrec/lz4 v2.6.1+incompatible
@@ -52,6 +53,7 @@ require (
 	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.30.0
 	go.opentelemetry.io/otel/sdk v1.30.0
 	go.opentelemetry.io/otel/trace v1.30.0
+	go.uber.org/multierr v1.11.0
 	go.uber.org/zap v1.27.0
 	golang.org/x/crypto v0.27.0
 	golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1
@@ -125,7 +127,6 @@ require (
 	go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 // indirect
 	go.opentelemetry.io/otel/metric v1.30.0 // indirect
 	go.opentelemetry.io/proto/otlp v1.3.1 // indirect
-	go.uber.org/multierr v1.11.0 // indirect
 	golang.org/x/time v0.6.0 // indirect
 	google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 // indirect
 	google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect
diff --git a/go.sum b/go.sum
index 437d5f5c..2398cce6 100644
--- a/go.sum
+++ b/go.sum
@@ -204,6 +204,8 @@ github.com/minio/minio-go/v7 v7.0.77 h1:GaGghJRg9nwDVlNbwYjSDJT1rqltQkBFDsypWX1v
 github.com/minio/minio-go/v7 v7.0.77/go.mod h1:AVM3IUN6WwKzmwBxVdjzhH8xq+f57JSbbvzqvUzR6eg=
 github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78=
 github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=
+github.com/mocktools/go-smtp-mock/v2 v2.3.1 h1:wq75NDSsOy5oHo/gEQQT0fRRaYKRqr1IdkjhIPXxagM=
+github.com/mocktools/go-smtp-mock/v2 v2.3.1/go.mod h1:h9AOf/IXLSU2m/1u4zsjtOM/WddPwdOUBz56dV9f81M=
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 github.com/mxk/go-vss v1.2.0 h1:JpdOPc/P6B3XyRoddn0iMiG/ADBi3AuEsv8RlTb+JeE=
@@ -255,6 +257,7 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/studio-b12/gowebdav v0.9.0 h1:1j1sc9gQnNxbXXM4M/CebPOX4aXYtr7MojAVcN4dHjU=
diff --git a/internal/editor/editor.go b/internal/editor/editor.go
index 1e26e0ad..2870ce72 100644
--- a/internal/editor/editor.go
+++ b/internal/editor/editor.go
@@ -21,7 +21,7 @@
 // EditLoop launches OS-specific editor (VI, notepad.exe or another editor configured through environment variables)
 // It creates a temporary file with 'initial' contents and repeatedly invokes the editor until the provided 'parse' function
 // returns nil result indicating success. The 'parse' function is passed the contents of edited files without # line comments.
-func EditLoop(ctx context.Context, fname, initial string, parse func(updated string) error) error {
+func EditLoop(ctx context.Context, fname, initial string, withComments bool, parse func(updated string) error) error {
 	tmpDir, err := os.MkdirTemp("", "kopia")
 	if err != nil {
 		return errors.Wrap(err, "unable to create temp directory")
@@ -36,11 +36,11 @@ func EditLoop(ctx context.Context, fname, initial string, parse func(updated str
 	}
 
 	for {
-		if err := editFile(ctx, tmpFile); err != nil {
+		if err := EditFile(ctx, tmpFile); err != nil {
 			return errors.Wrap(err, "error launching editor")
 		}
 
-		txt, err := readAndStripComments(tmpFile)
+		txt, err := readAndStripComments(tmpFile, withComments)
 		if err != nil {
 			return errors.Wrap(err, "error parsing edited file")
 		}
@@ -63,7 +63,13 @@ func EditLoop(ctx context.Context, fname, initial string, parse func(updated str
 	}
 }
 
-func readAndStripComments(fname string) (string, error) {
+func readAndStripComments(fname string, withComments bool) (string, error) {
+	if !withComments {
+		b, err := os.ReadFile(fname) //nolint:gosec
+
+		return string(b), errors.Wrap(err, "error reading file")
+	}
+
 	f, err := os.Open(fname) //nolint:gosec
 	if err != nil {
 		return "", errors.Wrap(err, "error opening edited file")
@@ -75,6 +81,7 @@ func readAndStripComments(fname string) (string, error) {
 	s := bufio.NewScanner(f)
 	for s.Scan() {
 		l := s.Text()
+
 		if strings.HasPrefix(strings.TrimSpace(l), "#") {
 			continue
 		}
@@ -87,7 +94,11 @@ func readAndStripComments(fname string) (string, error) {
 	return strings.Join(result, "\n"), nil
 }
 
-func editFile(ctx context.Context, file string) error {
+// EditFile launches the OS-specific editor (VI, notepad.exe or another editor configured through environment variables)
+// to edit the specified file and waits for it to complete.
+//
+//nolint:gochecknoglobals
+var EditFile = func(ctx context.Context, file string) error {
 	editor, editorArgs := getEditorCommand()
 
 	var args []string
diff --git a/internal/grpcapi/repository_server.pb.go b/internal/grpcapi/repository_server.pb.go
index be5c2f42..0a3c85d7 100644
--- a/internal/grpcapi/repository_server.pb.go
+++ b/internal/grpcapi/repository_server.pb.go
@@ -1459,6 +1459,107 @@ func (x *ApplyRetentionPolicyResponse) GetManifestIds() []string {
 	return nil
 }
 
+type SendNotificationRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	TemplateName string `protobuf:"bytes,1,opt,name=template_name,json=templateName,proto3" json:"template_name,omitempty"`
+	EventArgs    []byte `protobuf:"bytes,2,opt,name=event_args,json=eventArgs,proto3" json:"event_args,omitempty"` // JSON-encoded
+	Severity     int32  `protobuf:"varint,3,opt,name=severity,proto3" json:"severity,omitempty"`
+}
+
+func (x *SendNotificationRequest) Reset() {
+	*x = SendNotificationRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_repository_server_proto_msgTypes[26]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *SendNotificationRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SendNotificationRequest) ProtoMessage() {}
+
+func (x *SendNotificationRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_repository_server_proto_msgTypes[26]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use SendNotificationRequest.ProtoReflect.Descriptor instead.
+func (*SendNotificationRequest) Descriptor() ([]byte, []int) {
+	return file_repository_server_proto_rawDescGZIP(), []int{26}
+}
+
+func (x *SendNotificationRequest) GetTemplateName() string {
+	if x != nil {
+		return x.TemplateName
+	}
+	return ""
+}
+
+func (x *SendNotificationRequest) GetEventArgs() []byte {
+	if x != nil {
+		return x.EventArgs
+	}
+	return nil
+}
+
+func (x *SendNotificationRequest) GetSeverity() int32 {
+	if x != nil {
+		return x.Severity
+	}
+	return 0
+}
+
+type SendNotificationResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+}
+
+func (x *SendNotificationResponse) Reset() {
+	*x = SendNotificationResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_repository_server_proto_msgTypes[27]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *SendNotificationResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SendNotificationResponse) ProtoMessage() {}
+
+func (x *SendNotificationResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_repository_server_proto_msgTypes[27]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use SendNotificationResponse.ProtoReflect.Descriptor instead.
+func (*SendNotificationResponse) Descriptor() ([]byte, []int) {
+	return file_repository_server_proto_rawDescGZIP(), []int{27}
+}
+
 type SessionRequest struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
@@ -1483,13 +1584,14 @@ type SessionRequest struct {
 	//	*SessionRequest_DeleteManifest
 	//	*SessionRequest_PrefetchContents
 	//	*SessionRequest_ApplyRetentionPolicy
+	//	*SessionRequest_SendNotification
 	Request isSessionRequest_Request `protobuf_oneof:"request"`
 }
 
 func (x *SessionRequest) Reset() {
 	*x = SessionRequest{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_repository_server_proto_msgTypes[26]
+		mi := &file_repository_server_proto_msgTypes[28]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -1502,7 +1604,7 @@ func (x *SessionRequest) String() string {
 func (*SessionRequest) ProtoMessage() {}
 
 func (x *SessionRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_repository_server_proto_msgTypes[26]
+	mi := &file_repository_server_proto_msgTypes[28]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1515,7 +1617,7 @@ func (x *SessionRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use SessionRequest.ProtoReflect.Descriptor instead.
 func (*SessionRequest) Descriptor() ([]byte, []int) {
-	return file_repository_server_proto_rawDescGZIP(), []int{26}
+	return file_repository_server_proto_rawDescGZIP(), []int{28}
 }
 
 func (x *SessionRequest) GetRequestId() int64 {
@@ -1616,6 +1718,13 @@ func (x *SessionRequest) GetApplyRetentionPolicy() *ApplyRetentionPolicyRequest
 	return nil
 }
 
+func (x *SessionRequest) GetSendNotification() *SendNotificationRequest {
+	if x, ok := x.GetRequest().(*SessionRequest_SendNotification); ok {
+		return x.SendNotification
+	}
+	return nil
+}
+
 type isSessionRequest_Request interface {
 	isSessionRequest_Request()
 }
@@ -1664,6 +1773,10 @@ type SessionRequest_ApplyRetentionPolicy struct {
 	ApplyRetentionPolicy *ApplyRetentionPolicyRequest `protobuf:"bytes,20,opt,name=apply_retention_policy,json=applyRetentionPolicy,proto3,oneof"`
 }
 
+type SessionRequest_SendNotification struct {
+	SendNotification *SendNotificationRequest `protobuf:"bytes,21,opt,name=send_notification,json=sendNotification,proto3,oneof"`
+}
+
 func (*SessionRequest_InitializeSession) isSessionRequest_Request() {}
 
 func (*SessionRequest_GetContentInfo) isSessionRequest_Request() {}
@@ -1686,6 +1799,8 @@ func (*SessionRequest_PrefetchContents) isSessionRequest_Request() {}
 
 func (*SessionRequest_ApplyRetentionPolicy) isSessionRequest_Request() {}
 
+func (*SessionRequest_SendNotification) isSessionRequest_Request() {}
+
 type SessionResponse struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
@@ -1707,13 +1822,14 @@ type SessionResponse struct {
 	//	*SessionResponse_DeleteManifest
 	//	*SessionResponse_PrefetchContents
 	//	*SessionResponse_ApplyRetentionPolicy
+	//	*SessionResponse_SendNotification
 	Response isSessionResponse_Response `protobuf_oneof:"response"`
 }
 
 func (x *SessionResponse) Reset() {
 	*x = SessionResponse{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_repository_server_proto_msgTypes[27]
+		mi := &file_repository_server_proto_msgTypes[29]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -1726,7 +1842,7 @@ func (x *SessionResponse) String() string {
 func (*SessionResponse) ProtoMessage() {}
 
 func (x *SessionResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_repository_server_proto_msgTypes[27]
+	mi := &file_repository_server_proto_msgTypes[29]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1739,7 +1855,7 @@ func (x *SessionResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use SessionResponse.ProtoReflect.Descriptor instead.
 func (*SessionResponse) Descriptor() ([]byte, []int) {
-	return file_repository_server_proto_rawDescGZIP(), []int{27}
+	return file_repository_server_proto_rawDescGZIP(), []int{29}
 }
 
 func (x *SessionResponse) GetRequestId() int64 {
@@ -1847,6 +1963,13 @@ func (x *SessionResponse) GetApplyRetentionPolicy() *ApplyRetentionPolicyRespons
 	return nil
 }
 
+func (x *SessionResponse) GetSendNotification() *SendNotificationResponse {
+	if x, ok := x.GetResponse().(*SessionResponse_SendNotification); ok {
+		return x.SendNotification
+	}
+	return nil
+}
+
 type isSessionResponse_Response interface {
 	isSessionResponse_Response()
 }
@@ -1899,6 +2022,10 @@ type SessionResponse_ApplyRetentionPolicy struct {
 	ApplyRetentionPolicy *ApplyRetentionPolicyResponse `protobuf:"bytes,20,opt,name=apply_retention_policy,json=applyRetentionPolicy,proto3,oneof"`
 }
 
+type SessionResponse_SendNotification struct {
+	SendNotification *SendNotificationResponse `protobuf:"bytes,21,opt,name=send_notification,json=sendNotification,proto3,oneof"`
+}
+
 func (*SessionResponse_Error) isSessionResponse_Response() {}
 
 func (*SessionResponse_InitializeSession) isSessionResponse_Response() {}
@@ -1923,6 +2050,8 @@ func (*SessionResponse_PrefetchContents) isSessionResponse_Response() {}
 
 func (*SessionResponse_ApplyRetentionPolicy) isSessionResponse_Response() {}
 
+func (*SessionResponse_SendNotification) isSessionResponse_Response() {}
+
 var File_repository_server_proto protoreflect.FileDescriptor
 
 var file_repository_server_proto_rawDesc = []byte{
@@ -2095,151 +2224,172 @@ func (*SessionResponse_ApplyRetentionPolicy) isSessionResponse_Response() {}
 	0x6f, 0x6e, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
 	0x12, 0x21, 0x0a, 0x0c, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x73,
 	0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0b, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74,
-	0x49, 0x64, 0x73, 0x22, 0xd0, 0x08, 0x0a, 0x0e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52,
-	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73,
-	0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75,
-	0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x57, 0x0a, 0x0d, 0x74, 0x72, 0x61, 0x63, 0x65, 0x5f, 0x63,
-	0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x6b,
+	0x49, 0x64, 0x73, 0x22, 0x79, 0x0a, 0x17, 0x53, 0x65, 0x6e, 0x64, 0x4e, 0x6f, 0x74, 0x69, 0x66,
+	0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x23,
+	0x0a, 0x0d, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x4e,
+	0x61, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x61, 0x72, 0x67,
+	0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x41, 0x72,
+	0x67, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x76, 0x65, 0x72, 0x69, 0x74, 0x79, 0x18, 0x03,
+	0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x73, 0x65, 0x76, 0x65, 0x72, 0x69, 0x74, 0x79, 0x22, 0x1a,
+	0x0a, 0x18, 0x53, 0x65, 0x6e, 0x64, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xaa, 0x09, 0x0a, 0x0e, 0x53,
+	0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a,
+	0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
+	0x03, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x57, 0x0a, 0x0d,
+	0x74, 0x72, 0x61, 0x63, 0x65, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20,
+	0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f,
+	0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x54, 0x72, 0x61, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65,
+	0x78, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x74, 0x72, 0x61, 0x63, 0x65, 0x43, 0x6f,
+	0x6e, 0x74, 0x65, 0x78, 0x74, 0x12, 0x5b, 0x0a, 0x12, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c,
+	0x69, 0x7a, 0x65, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28,
+	0x0b, 0x32, 0x2a, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69,
+	0x74, 0x6f, 0x72, 0x79, 0x2e, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x53,
+	0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52,
+	0x11, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69,
+	0x6f, 0x6e, 0x12, 0x53, 0x0a, 0x10, 0x67, 0x65, 0x74, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e,
+	0x74, 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6b,
+	0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e,
+	0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0e, 0x67, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74,
+	0x65, 0x6e, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x36, 0x0a, 0x05, 0x66, 0x6c, 0x75, 0x73, 0x68,
+	0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72,
+	0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x46, 0x6c, 0x75, 0x73, 0x68, 0x52,
+	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x05, 0x66, 0x6c, 0x75, 0x73, 0x68, 0x12,
+	0x4c, 0x0a, 0x0d, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74,
+	0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72,
+	0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x43,
+	0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52,
+	0x0c, 0x77, 0x72, 0x69, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x46, 0x0a,
+	0x0b, 0x67, 0x65, 0x74, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x0e, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73,
+	0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0a, 0x67, 0x65, 0x74, 0x43, 0x6f,
+	0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x49, 0x0a, 0x0c, 0x67, 0x65, 0x74, 0x5f, 0x6d, 0x61, 0x6e,
+	0x69, 0x66, 0x65, 0x73, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6b, 0x6f,
+	0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x47,
+	0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
+	0x74, 0x48, 0x00, 0x52, 0x0b, 0x67, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74,
+	0x12, 0x49, 0x0a, 0x0c, 0x70, 0x75, 0x74, 0x5f, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74,
+	0x18, 0x10, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72,
+	0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x50, 0x75, 0x74, 0x4d, 0x61, 0x6e,
+	0x69, 0x66, 0x65, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0b,
+	0x70, 0x75, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x4f, 0x0a, 0x0e, 0x66,
+	0x69, 0x6e, 0x64, 0x5f, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x73, 0x18, 0x11, 0x20,
+	0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f,
+	0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x46, 0x69, 0x6e, 0x64, 0x4d, 0x61, 0x6e, 0x69, 0x66,
+	0x65, 0x73, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0d, 0x66,
+	0x69, 0x6e, 0x64, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x73, 0x12, 0x52, 0x0a, 0x0f,
+	0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x5f, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x18,
+	0x12, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65,
+	0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d,
+	0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00,
+	0x52, 0x0e, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74,
+	0x12, 0x58, 0x0a, 0x11, 0x70, 0x72, 0x65, 0x66, 0x65, 0x74, 0x63, 0x68, 0x5f, 0x63, 0x6f, 0x6e,
+	0x74, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x13, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x6b, 0x6f,
+	0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x50,
+	0x72, 0x65, 0x66, 0x65, 0x74, 0x63, 0x68, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x52,
+	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x10, 0x70, 0x72, 0x65, 0x66, 0x65, 0x74,
+	0x63, 0x68, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x65, 0x0a, 0x16, 0x61, 0x70,
+	0x70, 0x6c, 0x79, 0x5f, 0x72, 0x65, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x70, 0x6f,
+	0x6c, 0x69, 0x63, 0x79, 0x18, 0x14, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2d, 0x2e, 0x6b, 0x6f, 0x70,
+	0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x41, 0x70,
+	0x70, 0x6c, 0x79, 0x52, 0x65, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x6c, 0x69,
+	0x63, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x14, 0x61, 0x70, 0x70,
+	0x6c, 0x79, 0x52, 0x65, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x6c, 0x69, 0x63,
+	0x79, 0x12, 0x58, 0x0a, 0x11, 0x73, 0x65, 0x6e, 0x64, 0x5f, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x69,
+	0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x15, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x6b,
 	0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e,
-	0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x54,
+	0x53, 0x65, 0x6e, 0x64, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x10, 0x73, 0x65, 0x6e, 0x64, 0x4e,
+	0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x1a, 0x3f, 0x0a, 0x11, 0x54,
 	0x72, 0x61, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79,
-	0x52, 0x0c, 0x74, 0x72, 0x61, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x12, 0x5b,
+	0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b,
+	0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x09, 0x0a, 0x07,
+	0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xf2, 0x08, 0x0a, 0x0f, 0x53, 0x65, 0x73, 0x73,
+	0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72,
+	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52,
+	0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x68, 0x61,
+	0x73, 0x5f, 0x6d, 0x6f, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x68, 0x61,
+	0x73, 0x4d, 0x6f, 0x72, 0x65, 0x12, 0x37, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70,
+	0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73,
+	0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x5c,
 	0x0a, 0x12, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x5f, 0x73, 0x65, 0x73,
-	0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x6b, 0x6f, 0x70,
+	0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x6b, 0x6f, 0x70,
 	0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x49, 0x6e,
 	0x69, 0x74, 0x69, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52,
-	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x11, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61,
-	0x6c, 0x69, 0x7a, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x53, 0x0a, 0x10, 0x67,
-	0x65, 0x74, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x18,
-	0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65,
-	0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74,
-	0x65, 0x6e, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00,
-	0x52, 0x0e, 0x67, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x66, 0x6f,
-	0x12, 0x36, 0x0a, 0x05, 0x66, 0x6c, 0x75, 0x73, 0x68, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32,
-	0x1e, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f,
-	0x72, 0x79, 0x2e, 0x46, 0x6c, 0x75, 0x73, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48,
-	0x00, 0x52, 0x05, 0x66, 0x6c, 0x75, 0x73, 0x68, 0x12, 0x4c, 0x0a, 0x0d, 0x77, 0x72, 0x69, 0x74,
-	0x65, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32,
-	0x25, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f,
-	0x72, 0x79, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x52,
-	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0c, 0x77, 0x72, 0x69, 0x74, 0x65, 0x43,
-	0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x46, 0x0a, 0x0b, 0x67, 0x65, 0x74, 0x5f, 0x63, 0x6f,
-	0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6b, 0x6f,
-	0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x47,
-	0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
-	0x48, 0x00, 0x52, 0x0a, 0x67, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x49,
-	0x0a, 0x0c, 0x67, 0x65, 0x74, 0x5f, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x18, 0x0f,
-	0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70,
-	0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66,
-	0x65, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0b, 0x67, 0x65,
-	0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x49, 0x0a, 0x0c, 0x70, 0x75, 0x74,
-	0x5f, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x18, 0x10, 0x20, 0x01, 0x28, 0x0b, 0x32,
+	0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x11, 0x69, 0x6e, 0x69, 0x74, 0x69,
+	0x61, 0x6c, 0x69, 0x7a, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x54, 0x0a, 0x10,
+	0x67, 0x65, 0x74, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x6e, 0x66, 0x6f,
+	0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72,
+	0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e,
+	0x74, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
+	0x48, 0x00, 0x52, 0x0e, 0x67, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, 0x6e,
+	0x66, 0x6f, 0x12, 0x37, 0x0a, 0x05, 0x66, 0x6c, 0x75, 0x73, 0x68, 0x18, 0x0c, 0x20, 0x01, 0x28,
+	0x0b, 0x32, 0x1f, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69,
+	0x74, 0x6f, 0x72, 0x79, 0x2e, 0x46, 0x6c, 0x75, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+	0x73, 0x65, 0x48, 0x00, 0x52, 0x05, 0x66, 0x6c, 0x75, 0x73, 0x68, 0x12, 0x4d, 0x0a, 0x0d, 0x77,
+	0x72, 0x69, 0x74, 0x65, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x0d, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x26, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73,
+	0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65,
+	0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x0c, 0x77, 0x72,
+	0x69, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x47, 0x0a, 0x0b, 0x67, 0x65,
+	0x74, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32,
 	0x24, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f,
-	0x72, 0x79, 0x2e, 0x50, 0x75, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x52, 0x65,
-	0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0b, 0x70, 0x75, 0x74, 0x4d, 0x61, 0x6e, 0x69,
-	0x66, 0x65, 0x73, 0x74, 0x12, 0x4f, 0x0a, 0x0e, 0x66, 0x69, 0x6e, 0x64, 0x5f, 0x6d, 0x61, 0x6e,
-	0x69, 0x66, 0x65, 0x73, 0x74, 0x73, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x6b,
-	0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e,
-	0x46, 0x69, 0x6e, 0x64, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x73, 0x52, 0x65, 0x71,
-	0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0d, 0x66, 0x69, 0x6e, 0x64, 0x4d, 0x61, 0x6e, 0x69,
-	0x66, 0x65, 0x73, 0x74, 0x73, 0x12, 0x52, 0x0a, 0x0f, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x5f,
-	0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x18, 0x12, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27,
-	0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72,
-	0x79, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74,
-	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0e, 0x64, 0x65, 0x6c, 0x65, 0x74,
-	0x65, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x58, 0x0a, 0x11, 0x70, 0x72, 0x65,
-	0x66, 0x65, 0x74, 0x63, 0x68, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x13,
-	0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70,
-	0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x50, 0x72, 0x65, 0x66, 0x65, 0x74, 0x63, 0x68,
-	0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48,
-	0x00, 0x52, 0x10, 0x70, 0x72, 0x65, 0x66, 0x65, 0x74, 0x63, 0x68, 0x43, 0x6f, 0x6e, 0x74, 0x65,
-	0x6e, 0x74, 0x73, 0x12, 0x65, 0x0a, 0x16, 0x61, 0x70, 0x70, 0x6c, 0x79, 0x5f, 0x72, 0x65, 0x74,
-	0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x18, 0x14, 0x20,
-	0x01, 0x28, 0x0b, 0x32, 0x2d, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f,
-	0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x74, 0x65,
-	0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65,
-	0x73, 0x74, 0x48, 0x00, 0x52, 0x14, 0x61, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x74, 0x65, 0x6e,
-	0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x1a, 0x3f, 0x0a, 0x11, 0x54, 0x72,
-	0x61, 0x63, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12,
-	0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65,
-	0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
-	0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x72,
-	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x97, 0x08, 0x0a, 0x0f, 0x53, 0x65, 0x73, 0x73, 0x69,
-	0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65,
-	0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09,
-	0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x68, 0x61, 0x73,
-	0x5f, 0x6d, 0x6f, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x68, 0x61, 0x73,
-	0x4d, 0x6f, 0x72, 0x65, 0x12, 0x37, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20,
-	0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f,
-	0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70,
-	0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x5c, 0x0a,
-	0x12, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x5f, 0x73, 0x65, 0x73, 0x73,
-	0x69, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x6b, 0x6f, 0x70, 0x69,
-	0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x49, 0x6e, 0x69,
-	0x74, 0x69, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65,
-	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x11, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61,
-	0x6c, 0x69, 0x7a, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x54, 0x0a, 0x10, 0x67,
-	0x65, 0x74, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x18,
-	0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65,
-	0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74,
-	0x65, 0x6e, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48,
-	0x00, 0x52, 0x0e, 0x67, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x66,
-	0x6f, 0x12, 0x37, 0x0a, 0x05, 0x66, 0x6c, 0x75, 0x73, 0x68, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b,
-	0x32, 0x1f, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74,
-	0x6f, 0x72, 0x79, 0x2e, 0x46, 0x6c, 0x75, 0x73, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
-	0x65, 0x48, 0x00, 0x52, 0x05, 0x66, 0x6c, 0x75, 0x73, 0x68, 0x12, 0x4d, 0x0a, 0x0d, 0x77, 0x72,
-	0x69, 0x74, 0x65, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x0d, 0x20, 0x01, 0x28,
-	0x0b, 0x32, 0x26, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69,
-	0x74, 0x6f, 0x72, 0x79, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e,
-	0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x0c, 0x77, 0x72, 0x69,
-	0x74, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x47, 0x0a, 0x0b, 0x67, 0x65, 0x74,
-	0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24,
-	0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72,
-	0x79, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70,
-	0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x0a, 0x67, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65,
-	0x6e, 0x74, 0x12, 0x4a, 0x0a, 0x0c, 0x67, 0x65, 0x74, 0x5f, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65,
-	0x73, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61,
-	0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x47, 0x65, 0x74, 0x4d,
-	0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48,
-	0x00, 0x52, 0x0b, 0x67, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x4a,
-	0x0a, 0x0c, 0x70, 0x75, 0x74, 0x5f, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x18, 0x10,
-	0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70,
-	0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x50, 0x75, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66,
-	0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x0b, 0x70,
-	0x75, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x50, 0x0a, 0x0e, 0x66, 0x69,
-	0x6e, 0x64, 0x5f, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x73, 0x18, 0x11, 0x20, 0x01,
-	0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73,
-	0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x46, 0x69, 0x6e, 0x64, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65,
-	0x73, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x0d, 0x66,
-	0x69, 0x6e, 0x64, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x73, 0x12, 0x53, 0x0a, 0x0f,
-	0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x5f, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x18,
-	0x12, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65,
-	0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d,
-	0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48,
-	0x00, 0x52, 0x0e, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73,
-	0x74, 0x12, 0x59, 0x0a, 0x11, 0x70, 0x72, 0x65, 0x66, 0x65, 0x74, 0x63, 0x68, 0x5f, 0x63, 0x6f,
-	0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x13, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x6b,
+	0x72, 0x79, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73,
+	0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x0a, 0x67, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74,
+	0x65, 0x6e, 0x74, 0x12, 0x4a, 0x0a, 0x0c, 0x67, 0x65, 0x74, 0x5f, 0x6d, 0x61, 0x6e, 0x69, 0x66,
+	0x65, 0x73, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6b, 0x6f, 0x70, 0x69,
+	0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x47, 0x65, 0x74,
+	0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
+	0x48, 0x00, 0x52, 0x0b, 0x67, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12,
+	0x4a, 0x0a, 0x0c, 0x70, 0x75, 0x74, 0x5f, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x18,
+	0x10, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65,
+	0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x50, 0x75, 0x74, 0x4d, 0x61, 0x6e, 0x69,
+	0x66, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x0b,
+	0x70, 0x75, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x50, 0x0a, 0x0e, 0x66,
+	0x69, 0x6e, 0x64, 0x5f, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x73, 0x18, 0x11, 0x20,
+	0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f,
+	0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x46, 0x69, 0x6e, 0x64, 0x4d, 0x61, 0x6e, 0x69, 0x66,
+	0x65, 0x73, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x0d,
+	0x66, 0x69, 0x6e, 0x64, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x73, 0x12, 0x53, 0x0a,
+	0x0f, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x5f, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74,
+	0x18, 0x12, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72,
+	0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65,
+	0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
+	0x48, 0x00, 0x52, 0x0e, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65,
+	0x73, 0x74, 0x12, 0x59, 0x0a, 0x11, 0x70, 0x72, 0x65, 0x66, 0x65, 0x74, 0x63, 0x68, 0x5f, 0x63,
+	0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x13, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2a, 0x2e,
+	0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79,
+	0x2e, 0x50, 0x72, 0x65, 0x66, 0x65, 0x74, 0x63, 0x68, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74,
+	0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x10, 0x70, 0x72, 0x65,
+	0x66, 0x65, 0x74, 0x63, 0x68, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x66, 0x0a,
+	0x16, 0x61, 0x70, 0x70, 0x6c, 0x79, 0x5f, 0x72, 0x65, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e,
+	0x5f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x18, 0x14, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2e, 0x2e,
+	0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79,
+	0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x50,
+	0x6f, 0x6c, 0x69, 0x63, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52,
+	0x14, 0x61, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x50,
+	0x6f, 0x6c, 0x69, 0x63, 0x79, 0x12, 0x59, 0x0a, 0x11, 0x73, 0x65, 0x6e, 0x64, 0x5f, 0x6e, 0x6f,
+	0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x15, 0x20, 0x01, 0x28, 0x0b,
+	0x32, 0x2a, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74,
+	0x6f, 0x72, 0x79, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61,
+	0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x10,
+	0x73, 0x65, 0x6e, 0x64, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+	0x42, 0x0a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x65, 0x0a, 0x0f,
+	0x4b, 0x6f, 0x70, 0x69, 0x61, 0x52, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x12,
+	0x52, 0x0a, 0x07, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x2e, 0x6b, 0x6f, 0x70,
+	0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e, 0x53, 0x65,
+	0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x6b,
 	0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e,
-	0x50, 0x72, 0x65, 0x66, 0x65, 0x74, 0x63, 0x68, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73,
-	0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x10, 0x70, 0x72, 0x65, 0x66,
-	0x65, 0x74, 0x63, 0x68, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x66, 0x0a, 0x16,
-	0x61, 0x70, 0x70, 0x6c, 0x79, 0x5f, 0x72, 0x65, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x5f,
-	0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x18, 0x14, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x6b,
-	0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x2e,
-	0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f,
-	0x6c, 0x69, 0x63, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x14,
-	0x61, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f,
-	0x6c, 0x69, 0x63, 0x79, 0x42, 0x0a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
-	0x32, 0x65, 0x0a, 0x0f, 0x4b, 0x6f, 0x70, 0x69, 0x61, 0x52, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74,
-	0x6f, 0x72, 0x79, 0x12, 0x52, 0x0a, 0x07, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x20,
-	0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72,
-	0x79, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
-	0x1a, 0x21, 0x2e, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74,
-	0x6f, 0x72, 0x79, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f,
-	0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, 0x29, 0x5a, 0x27, 0x67, 0x69, 0x74, 0x68, 0x75,
-	0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x2f, 0x6b, 0x6f, 0x70, 0x69,
-	0x61, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x67, 0x72, 0x70, 0x63, 0x61,
-	0x70, 0x69, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+	0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28,
+	0x01, 0x30, 0x01, 0x42, 0x29, 0x5a, 0x27, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f,
+	0x6d, 0x2f, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x2f, 0x6b, 0x6f, 0x70, 0x69, 0x61, 0x2f, 0x69, 0x6e,
+	0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x67, 0x72, 0x70, 0x63, 0x61, 0x70, 0x69, 0x62, 0x06,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
 }
 
 var (
@@ -2255,7 +2405,7 @@ func file_repository_server_proto_rawDescGZIP() []byte {
 }
 
 var file_repository_server_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
-var file_repository_server_proto_msgTypes = make([]protoimpl.MessageInfo, 32)
+var file_repository_server_proto_msgTypes = make([]protoimpl.MessageInfo, 34)
 var file_repository_server_proto_goTypes = []interface{}{
 	(ErrorResponse_Code)(0),              // 0: kopia_repository.ErrorResponse.Code
 	(*ContentInfo)(nil),                  // 1: kopia_repository.ContentInfo
@@ -2284,23 +2434,25 @@ func file_repository_server_proto_rawDescGZIP() []byte {
 	(*PrefetchContentsResponse)(nil),     // 24: kopia_repository.PrefetchContentsResponse
 	(*ApplyRetentionPolicyRequest)(nil),  // 25: kopia_repository.ApplyRetentionPolicyRequest
 	(*ApplyRetentionPolicyResponse)(nil), // 26: kopia_repository.ApplyRetentionPolicyResponse
-	(*SessionRequest)(nil),               // 27: kopia_repository.SessionRequest
-	(*SessionResponse)(nil),              // 28: kopia_repository.SessionResponse
-	nil,                                  // 29: kopia_repository.ManifestEntryMetadata.LabelsEntry
-	nil,                                  // 30: kopia_repository.PutManifestRequest.LabelsEntry
-	nil,                                  // 31: kopia_repository.FindManifestsRequest.LabelsEntry
-	nil,                                  // 32: kopia_repository.SessionRequest.TraceContextEntry
+	(*SendNotificationRequest)(nil),      // 27: kopia_repository.SendNotificationRequest
+	(*SendNotificationResponse)(nil),     // 28: kopia_repository.SendNotificationResponse
+	(*SessionRequest)(nil),               // 29: kopia_repository.SessionRequest
+	(*SessionResponse)(nil),              // 30: kopia_repository.SessionResponse
+	nil,                                  // 31: kopia_repository.ManifestEntryMetadata.LabelsEntry
+	nil,                                  // 32: kopia_repository.PutManifestRequest.LabelsEntry
+	nil,                                  // 33: kopia_repository.FindManifestsRequest.LabelsEntry
+	nil,                                  // 34: kopia_repository.SessionRequest.TraceContextEntry
 }
 var file_repository_server_proto_depIdxs = []int32{
-	29, // 0: kopia_repository.ManifestEntryMetadata.labels:type_name -> kopia_repository.ManifestEntryMetadata.LabelsEntry
+	31, // 0: kopia_repository.ManifestEntryMetadata.labels:type_name -> kopia_repository.ManifestEntryMetadata.LabelsEntry
 	0,  // 1: kopia_repository.ErrorResponse.code:type_name -> kopia_repository.ErrorResponse.Code
 	4,  // 2: kopia_repository.InitializeSessionResponse.parameters:type_name -> kopia_repository.RepositoryParameters
 	1,  // 3: kopia_repository.GetContentInfoResponse.info:type_name -> kopia_repository.ContentInfo
 	2,  // 4: kopia_repository.GetManifestResponse.metadata:type_name -> kopia_repository.ManifestEntryMetadata
-	30, // 5: kopia_repository.PutManifestRequest.labels:type_name -> kopia_repository.PutManifestRequest.LabelsEntry
-	31, // 6: kopia_repository.FindManifestsRequest.labels:type_name -> kopia_repository.FindManifestsRequest.LabelsEntry
+	32, // 5: kopia_repository.PutManifestRequest.labels:type_name -> kopia_repository.PutManifestRequest.LabelsEntry
+	33, // 6: kopia_repository.FindManifestsRequest.labels:type_name -> kopia_repository.FindManifestsRequest.LabelsEntry
 	2,  // 7: kopia_repository.FindManifestsResponse.metadata:type_name -> kopia_repository.ManifestEntryMetadata
-	32, // 8: kopia_repository.SessionRequest.trace_context:type_name -> kopia_repository.SessionRequest.TraceContextEntry
+	34, // 8: kopia_repository.SessionRequest.trace_context:type_name -> kopia_repository.SessionRequest.TraceContextEntry
 	5,  // 9: kopia_repository.SessionRequest.initialize_session:type_name -> kopia_repository.InitializeSessionRequest
 	7,  // 10: kopia_repository.SessionRequest.get_content_info:type_name -> kopia_repository.GetContentInfoRequest
 	11, // 11: kopia_repository.SessionRequest.flush:type_name -> kopia_repository.FlushRequest
@@ -2312,25 +2464,27 @@ func file_repository_server_proto_rawDescGZIP() []byte {
 	19, // 17: kopia_repository.SessionRequest.delete_manifest:type_name -> kopia_repository.DeleteManifestRequest
 	23, // 18: kopia_repository.SessionRequest.prefetch_contents:type_name -> kopia_repository.PrefetchContentsRequest
 	25, // 19: kopia_repository.SessionRequest.apply_retention_policy:type_name -> kopia_repository.ApplyRetentionPolicyRequest
-	3,  // 20: kopia_repository.SessionResponse.error:type_name -> kopia_repository.ErrorResponse
-	6,  // 21: kopia_repository.SessionResponse.initialize_session:type_name -> kopia_repository.InitializeSessionResponse
-	8,  // 22: kopia_repository.SessionResponse.get_content_info:type_name -> kopia_repository.GetContentInfoResponse
-	12, // 23: kopia_repository.SessionResponse.flush:type_name -> kopia_repository.FlushResponse
-	14, // 24: kopia_repository.SessionResponse.write_content:type_name -> kopia_repository.WriteContentResponse
-	10, // 25: kopia_repository.SessionResponse.get_content:type_name -> kopia_repository.GetContentResponse
-	16, // 26: kopia_repository.SessionResponse.get_manifest:type_name -> kopia_repository.GetManifestResponse
-	18, // 27: kopia_repository.SessionResponse.put_manifest:type_name -> kopia_repository.PutManifestResponse
-	22, // 28: kopia_repository.SessionResponse.find_manifests:type_name -> kopia_repository.FindManifestsResponse
-	20, // 29: kopia_repository.SessionResponse.delete_manifest:type_name -> kopia_repository.DeleteManifestResponse
-	24, // 30: kopia_repository.SessionResponse.prefetch_contents:type_name -> kopia_repository.PrefetchContentsResponse
-	26, // 31: kopia_repository.SessionResponse.apply_retention_policy:type_name -> kopia_repository.ApplyRetentionPolicyResponse
-	27, // 32: kopia_repository.KopiaRepository.Session:input_type -> kopia_repository.SessionRequest
-	28, // 33: kopia_repository.KopiaRepository.Session:output_type -> kopia_repository.SessionResponse
-	33, // [33:34] is the sub-list for method output_type
-	32, // [32:33] is the sub-list for method input_type
-	32, // [32:32] is the sub-list for extension type_name
-	32, // [32:32] is the sub-list for extension extendee
-	0,  // [0:32] is the sub-list for field type_name
+	27, // 20: kopia_repository.SessionRequest.send_notification:type_name -> kopia_repository.SendNotificationRequest
+	3,  // 21: kopia_repository.SessionResponse.error:type_name -> kopia_repository.ErrorResponse
+	6,  // 22: kopia_repository.SessionResponse.initialize_session:type_name -> kopia_repository.InitializeSessionResponse
+	8,  // 23: kopia_repository.SessionResponse.get_content_info:type_name -> kopia_repository.GetContentInfoResponse
+	12, // 24: kopia_repository.SessionResponse.flush:type_name -> kopia_repository.FlushResponse
+	14, // 25: kopia_repository.SessionResponse.write_content:type_name -> kopia_repository.WriteContentResponse
+	10, // 26: kopia_repository.SessionResponse.get_content:type_name -> kopia_repository.GetContentResponse
+	16, // 27: kopia_repository.SessionResponse.get_manifest:type_name -> kopia_repository.GetManifestResponse
+	18, // 28: kopia_repository.SessionResponse.put_manifest:type_name -> kopia_repository.PutManifestResponse
+	22, // 29: kopia_repository.SessionResponse.find_manifests:type_name -> kopia_repository.FindManifestsResponse
+	20, // 30: kopia_repository.SessionResponse.delete_manifest:type_name -> kopia_repository.DeleteManifestResponse
+	24, // 31: kopia_repository.SessionResponse.prefetch_contents:type_name -> kopia_repository.PrefetchContentsResponse
+	26, // 32: kopia_repository.SessionResponse.apply_retention_policy:type_name -> kopia_repository.ApplyRetentionPolicyResponse
+	28, // 33: kopia_repository.SessionResponse.send_notification:type_name -> kopia_repository.SendNotificationResponse
+	29, // 34: kopia_repository.KopiaRepository.Session:input_type -> kopia_repository.SessionRequest
+	30, // 35: kopia_repository.KopiaRepository.Session:output_type -> kopia_repository.SessionResponse
+	35, // [35:36] is the sub-list for method output_type
+	34, // [34:35] is the sub-list for method input_type
+	34, // [34:34] is the sub-list for extension type_name
+	34, // [34:34] is the sub-list for extension extendee
+	0,  // [0:34] is the sub-list for field type_name
 }
 
 func init() { file_repository_server_proto_init() }
@@ -2652,7 +2806,7 @@ func file_repository_server_proto_init() {
 			}
 		}
 		file_repository_server_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} {
-			switch v := v.(*SessionRequest); i {
+			switch v := v.(*SendNotificationRequest); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -2664,6 +2818,30 @@ func file_repository_server_proto_init() {
 			}
 		}
 		file_repository_server_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*SendNotificationResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_repository_server_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*SessionRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_repository_server_proto_msgTypes[29].Exporter = func(v interface{}, i int) interface{} {
 			switch v := v.(*SessionResponse); i {
 			case 0:
 				return &v.state
@@ -2676,7 +2854,7 @@ func file_repository_server_proto_init() {
 			}
 		}
 	}
-	file_repository_server_proto_msgTypes[26].OneofWrappers = []interface{}{
+	file_repository_server_proto_msgTypes[28].OneofWrappers = []interface{}{
 		(*SessionRequest_InitializeSession)(nil),
 		(*SessionRequest_GetContentInfo)(nil),
 		(*SessionRequest_Flush)(nil),
@@ -2688,8 +2866,9 @@ func file_repository_server_proto_init() {
 		(*SessionRequest_DeleteManifest)(nil),
 		(*SessionRequest_PrefetchContents)(nil),
 		(*SessionRequest_ApplyRetentionPolicy)(nil),
+		(*SessionRequest_SendNotification)(nil),
 	}
-	file_repository_server_proto_msgTypes[27].OneofWrappers = []interface{}{
+	file_repository_server_proto_msgTypes[29].OneofWrappers = []interface{}{
 		(*SessionResponse_Error)(nil),
 		(*SessionResponse_InitializeSession)(nil),
 		(*SessionResponse_GetContentInfo)(nil),
@@ -2702,6 +2881,7 @@ func file_repository_server_proto_init() {
 		(*SessionResponse_DeleteManifest)(nil),
 		(*SessionResponse_PrefetchContents)(nil),
 		(*SessionResponse_ApplyRetentionPolicy)(nil),
+		(*SessionResponse_SendNotification)(nil),
 	}
 	type x struct{}
 	out := protoimpl.TypeBuilder{
@@ -2709,7 +2889,7 @@ type x struct{
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
 			RawDescriptor: file_repository_server_proto_rawDesc,
 			NumEnums:      1,
-			NumMessages:   32,
+			NumMessages:   34,
 			NumExtensions: 0,
 			NumServices:   1,
 		},
diff --git a/internal/grpcapi/repository_server.proto b/internal/grpcapi/repository_server.proto
index c6edd354..062e43ba 100644
--- a/internal/grpcapi/repository_server.proto
+++ b/internal/grpcapi/repository_server.proto
@@ -145,6 +145,15 @@ message ApplyRetentionPolicyResponse {
   repeated string manifest_ids = 1;
 }
 
+message SendNotificationRequest {
+  string template_name = 1;
+  bytes event_args = 2; // JSON-encoded
+  int32 severity = 3;
+}
+
+message SendNotificationResponse {
+}
+
 message SessionRequest {
   int64 request_id = 1;
   map<string,string> trace_context = 2;
@@ -164,6 +173,7 @@ message SessionRequest {
     DeleteManifestRequest delete_manifest = 18;
     PrefetchContentsRequest prefetch_contents = 19;
     ApplyRetentionPolicyRequest apply_retention_policy = 20;
+    SendNotificationRequest send_notification = 21;
   }
 }
 
@@ -185,6 +195,7 @@ message SessionResponse {
     DeleteManifestResponse delete_manifest = 18;
     PrefetchContentsResponse prefetch_contents = 19;
     ApplyRetentionPolicyResponse apply_retention_policy = 20;
+    SendNotificationResponse send_notification = 21;
   }
 }
 
diff --git a/internal/server/grpc_session.go b/internal/server/grpc_session.go
index 9a3102b5..3779ea26 100644
--- a/internal/server/grpc_session.go
+++ b/internal/server/grpc_session.go
@@ -21,6 +21,7 @@
 	"github.com/kopia/kopia/internal/auth"
 	"github.com/kopia/kopia/internal/gather"
 	"github.com/kopia/kopia/internal/grpcapi"
+	"github.com/kopia/kopia/notification"
 	"github.com/kopia/kopia/repo"
 	"github.com/kopia/kopia/repo/compression"
 	"github.com/kopia/kopia/repo/content"
@@ -185,6 +186,9 @@ func handleSessionRequest(ctx context.Context, dw repo.DirectRepositoryWriter, a
 	case *grpcapi.SessionRequest_ApplyRetentionPolicy:
 		respond(handleApplyRetentionPolicyRequest(ctx, dw, authz, usernameAtHostname, inner.ApplyRetentionPolicy))
 
+	case *grpcapi.SessionRequest_SendNotification:
+		respond(handleSendNotificationRequest(ctx, dw, authz, inner.SendNotification))
+
 	case *grpcapi.SessionRequest_InitializeSession:
 		respond(errorResponse(errors.New("InitializeSession must be the first request in a session")))
 
@@ -484,6 +488,28 @@ func handleApplyRetentionPolicyRequest(ctx context.Context, rep repo.RepositoryW
 	}
 }
 
+func handleSendNotificationRequest(ctx context.Context, rep repo.RepositoryWriter, authz auth.AuthorizationInfo, req *grpcapi.SendNotificationRequest) *grpcapi.SessionResponse {
+	ctx, span := tracer.Start(ctx, "GRPCSession.SendNotification")
+	defer span.End()
+
+	if authz.ContentAccessLevel() < auth.AccessLevelAppend {
+		return accessDeniedResponse()
+	}
+
+	if err := notification.SendInternal(ctx, rep,
+		req.GetTemplateName(),
+		json.RawMessage(req.GetEventArgs()),
+		notification.Severity(req.GetSeverity())); err != nil {
+		return errorResponse(err)
+	}
+
+	return &grpcapi.SessionResponse{
+		Response: &grpcapi.SessionResponse_SendNotification{
+			SendNotification: &grpcapi.SendNotificationResponse{},
+		},
+	}
+}
+
 func accessDeniedResponse() *grpcapi.SessionResponse {
 	return &grpcapi.SessionResponse{
 		Response: &grpcapi.SessionResponse_Error{
diff --git a/notification/notification_send.go b/notification/notification_send.go
new file mode 100644
index 00000000..1a4c2b92
--- /dev/null
+++ b/notification/notification_send.go
@@ -0,0 +1,209 @@
+// Package notification provides a mechanism to send notifications for various events.
+package notification
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"os"
+	"time"
+
+	"github.com/pkg/errors"
+	"go.uber.org/multierr"
+
+	"github.com/kopia/kopia/internal/clock"
+	"github.com/kopia/kopia/notification/notifyprofile"
+	"github.com/kopia/kopia/notification/notifytemplate"
+	"github.com/kopia/kopia/notification/sender"
+	"github.com/kopia/kopia/repo"
+	"github.com/kopia/kopia/repo/logging"
+)
+
+var log = logging.Module("notification")
+
+// TemplateArgs represents the arguments passed to the notification template when rendering.
+type TemplateArgs struct {
+	Hostname          string
+	EventTime         time.Time
+	EventArgs         any
+	KopiaRepo         string
+	KopiaBuildInfo    string
+	KopiaBuildVersion string
+}
+
+// Severity represents the severity of a notification message.
+type Severity = sender.Severity
+
+const (
+	// SeverityVerbose includes all notification messages, including frequent and verbose ones.
+	SeverityVerbose Severity = -100
+
+	// SeveritySuccess is used for successful operations.
+	SeveritySuccess Severity = -10
+
+	// SeverityDefault includes notification messages enabled by default.
+	SeverityDefault Severity = 0
+
+	// SeverityReport is used for periodic reports.
+	SeverityReport Severity = 0
+
+	// SeverityWarning is used for warnings about potential issues.
+	SeverityWarning Severity = 10
+
+	// SeverityError is used for errors that require attention.
+	SeverityError Severity = 20
+)
+
+// SeverityToNumber maps severity names to numbers.
+//
+//nolint:gochecknoglobals
+var SeverityToNumber = map[string]Severity{
+	"verbose": SeverityVerbose,
+	"success": SeveritySuccess,
+	"report":  SeverityReport,
+	"warning": SeverityWarning,
+	"error":   SeverityError,
+}
+
+// SeverityToString maps severity numbers to names.
+//
+//nolint:gochecknoglobals
+var SeverityToString map[Severity]string
+
+func init() {
+	SeverityToString = make(map[Severity]string)
+
+	for k, v := range SeverityToNumber {
+		SeverityToString[v] = k
+	}
+}
+
+func notificationSendersFromRepo(ctx context.Context, rep repo.Repository, severity Severity) ([]sender.Sender, error) {
+	profiles, err := notifyprofile.ListProfiles(ctx, rep)
+	if err != nil {
+		return nil, errors.Wrap(err, "unable to list notification profiles")
+	}
+
+	var result []sender.Sender
+
+	for _, p := range profiles {
+		if severity < p.MinSeverity {
+			continue
+		}
+
+		s, err := sender.GetSender(ctx, p.ProfileName, p.MethodConfig.Type, p.MethodConfig.Config)
+		if err != nil {
+			log(ctx).Warnw("unable to create sender for notification profile", "profile", p.ProfileName, "err", err)
+			continue
+		}
+
+		result = append(result, s)
+	}
+
+	return result, nil
+}
+
+// Send sends a notification for the given event.
+// Any errors encountered during the process are logged.
+func Send(ctx context.Context, rep repo.Repository, templateName string, eventArgs any, sev Severity) {
+	// if we're connected to a repository server, send the notification there.
+	if rem, ok := rep.(repo.RemoteNotifications); ok {
+		jsonData, err := json.Marshal(eventArgs)
+		if err != nil {
+			log(ctx).Warnw("unable to marshal event args", "err", err)
+
+			return
+		}
+
+		if err := rem.SendNotification(ctx, templateName, jsonData, int32(sev)); err != nil {
+			log(ctx).Warnw("unable to send notification", "err", err)
+		}
+
+		return
+	}
+
+	if err := SendInternal(ctx, rep, templateName, eventArgs, sev); err != nil {
+		log(ctx).Warnw("unable to send notification", "err", err)
+	}
+}
+
+// SendInternal sends a notification for the given event and returns an error.
+func SendInternal(ctx context.Context, rep repo.Repository, templateName string, eventArgs any, sev Severity) error {
+	senders, err := notificationSendersFromRepo(ctx, rep, sev)
+	if err != nil {
+		return errors.Wrap(err, "unable to get notification senders")
+	}
+
+	var resultErr error
+
+	for _, s := range senders {
+		if err := SendTo(ctx, rep, s, templateName, eventArgs, sev); err != nil {
+			resultErr = multierr.Append(resultErr, err)
+		}
+	}
+
+	return resultErr //nolint:wrapcheck
+}
+
+// MakeTemplateArgs wraps event-specific arguments into TemplateArgs object.
+func MakeTemplateArgs(eventArgs any) TemplateArgs {
+	now := clock.Now()
+
+	h, _ := os.Hostname()
+	if h == "" {
+		h = "unknown hostname"
+	}
+
+	// prepare template arguments
+	return TemplateArgs{
+		Hostname:          h,
+		EventArgs:         eventArgs,
+		EventTime:         now,
+		KopiaRepo:         repo.BuildGitHubRepo,
+		KopiaBuildInfo:    repo.BuildInfo,
+		KopiaBuildVersion: repo.BuildVersion,
+	}
+}
+
+// SendTo sends a notification to the given sender.
+func SendTo(ctx context.Context, rep repo.Repository, s sender.Sender, templateName string, eventArgs any, sev Severity) error {
+	// execute template
+	var bodyBuf bytes.Buffer
+
+	tmpl, err := notifytemplate.ResolveTemplate(ctx, rep, s.ProfileName(), templateName, s.Format())
+	if err != nil {
+		return errors.Wrap(err, "unable to resolve notification template")
+	}
+
+	t, err := notifytemplate.ParseTemplate(tmpl)
+	if err != nil {
+		return errors.Wrap(err, "unable to parse notification template")
+	}
+
+	if err := t.Execute(&bodyBuf, MakeTemplateArgs(eventArgs)); err != nil {
+		return errors.Wrap(err, "unable to execute notification template")
+	}
+
+	// extract headers from the template
+	msg, err := sender.ParseMessage(ctx, &bodyBuf)
+	if err != nil {
+		return errors.Wrap(err, "unable to parse message from notification template")
+	}
+
+	msg.Severity = sev
+
+	var resultErr error
+
+	if err := s.Send(ctx, msg); err != nil {
+		resultErr = multierr.Append(resultErr, errors.Wrap(err, "unable to send notification message"))
+	}
+
+	return resultErr //nolint:wrapcheck
+}
+
+// SendTestNotification sends a test notification to the given sender.
+func SendTestNotification(ctx context.Context, rep repo.Repository, s sender.Sender) error {
+	log(ctx).Infof("Sending test notification to %v", s.Summary())
+
+	return SendTo(ctx, rep, s, notifytemplate.TestNotification, struct{}{}, SeveritySuccess)
+}
diff --git a/notification/notifyprofile/notification_profile.go b/notification/notifyprofile/notification_profile.go
new file mode 100644
index 00000000..af3c7130
--- /dev/null
+++ b/notification/notifyprofile/notification_profile.go
@@ -0,0 +1,111 @@
+// Package notifyprofile notification profile management.
+package notifyprofile
+
+import (
+	"context"
+
+	"github.com/pkg/errors"
+
+	"github.com/kopia/kopia/notification/sender"
+	"github.com/kopia/kopia/repo"
+	"github.com/kopia/kopia/repo/logging"
+	"github.com/kopia/kopia/repo/manifest"
+)
+
+var log = logging.Module("notification/profile")
+
+const profileNameKey = "profile"
+
+const notificationConfigManifestType = "notificationProfile"
+
+// Config is a struct that represents the configuration for a single notification profile.
+type Config struct {
+	ProfileName  string              `json:"profile"`
+	MethodConfig sender.MethodConfig `json:"method"`
+	MinSeverity  sender.Severity     `json:"minSeverity"`
+}
+
+// Summary contains JSON-serializable summary of a notification profile.
+type Summary struct {
+	ProfileName string `json:"profile"`
+	Type        string `json:"type"`
+	Summary     string `json:"summary"`
+	MinSeverity int32  `json:"minSeverity"`
+}
+
+// ListProfiles returns a list of notification profiles.
+func ListProfiles(ctx context.Context, rep repo.Repository) ([]Config, error) {
+	profileMetadata, err := rep.FindManifests(ctx,
+		map[string]string{
+			manifest.TypeLabelKey: notificationConfigManifestType,
+		})
+	if err != nil {
+		return nil, errors.Wrap(err, "unable to list notification profiles")
+	}
+
+	var profiles []Config
+
+	for _, m := range profileMetadata {
+		var pc Config
+		if _, err := rep.GetManifest(ctx, m.ID, &pc); err != nil {
+			return nil, errors.Wrap(err, "unable to get notification profile")
+		}
+
+		profiles = append(profiles, pc)
+	}
+
+	return profiles, nil
+}
+
+// GetProfile returns a notification profile by name.
+func GetProfile(ctx context.Context, rep repo.Repository, name string) (Config, bool, error) {
+	entries, err := rep.FindManifests(ctx, labelsForProfileName(name))
+	if err != nil {
+		return Config{}, false, errors.Wrap(err, "unable to list notification profiles")
+	}
+
+	if len(entries) == 0 {
+		return Config{}, false, nil
+	}
+
+	var pc Config
+
+	_, err = rep.GetManifest(ctx, manifest.PickLatestID(entries), &pc)
+
+	return pc, true, errors.Wrap(err, "unable to get notification profile")
+}
+
+// SaveProfile saves a notification profile.
+func SaveProfile(ctx context.Context, rep repo.RepositoryWriter, pc Config) error {
+	log(ctx).Debugf("saving notification profile %q with method %v", pc.ProfileName, pc.MethodConfig)
+
+	_, err := rep.ReplaceManifests(ctx, labelsForProfileName(pc.ProfileName), &pc)
+	if err != nil {
+		return errors.Wrap(err, "unable to save notification profile")
+	}
+
+	return nil
+}
+
+// DeleteProfile deletes a notification profile.
+func DeleteProfile(ctx context.Context, rep repo.RepositoryWriter, name string) error {
+	entries, err := rep.FindManifests(ctx, labelsForProfileName(name))
+	if err != nil {
+		return errors.Wrap(err, "unable to list notification profiles")
+	}
+
+	for _, e := range entries {
+		if err := rep.DeleteManifest(ctx, e.ID); err != nil {
+			return errors.Wrapf(err, "unable to delete notification profile %q", e.ID)
+		}
+	}
+
+	return nil
+}
+
+func labelsForProfileName(name string) map[string]string {
+	return map[string]string{
+		manifest.TypeLabelKey: notificationConfigManifestType,
+		profileNameKey:        name,
+	}
+}
diff --git a/notification/notifytemplate/embeddedtemplate.go b/notification/notifytemplate/embeddedtemplate.go
new file mode 100644
index 00000000..9e3841f8
--- /dev/null
+++ b/notification/notifytemplate/embeddedtemplate.go
@@ -0,0 +1,67 @@
+// Package notifytemplate provides a way to access notification templates.
+package notifytemplate
+
+import (
+	"embed"
+	"text/template"
+	"time"
+
+	"github.com/pkg/errors"
+
+	"github.com/kopia/kopia/fs"
+)
+
+//go:embed "*.html"
+//go:embed "*.txt"
+var embedded embed.FS
+
+// Template names.
+const (
+	TestNotification = "test-notification"
+)
+
+// Functions is a map of functions that can be used in templates.
+//
+//nolint:gochecknoglobals
+var Functions = template.FuncMap{
+	"toTime": func(t any) time.Time {
+		if t, ok := t.(time.Time); ok {
+			return t
+		}
+
+		if t, ok := t.(fs.UTCTimestamp); ok {
+			return t.ToTime()
+		}
+
+		return time.Time{}
+	},
+}
+
+// GetEmbeddedTemplate returns embedded template by name.
+func GetEmbeddedTemplate(templateName string) (string, error) {
+	b, err := embedded.ReadFile(templateName)
+	if err != nil {
+		return "", errors.Wrap(err, "unable to read embedded template")
+	}
+
+	return string(b), nil
+}
+
+// SupportedTemplates returns a list of supported template names.
+func SupportedTemplates() []string {
+	var s []string
+
+	entries, _ := embedded.ReadDir(".")
+
+	for _, e := range entries {
+		s = append(s, e.Name())
+	}
+
+	return s
+}
+
+// ParseTemplate parses a named template.
+func ParseTemplate(tmpl string) (*template.Template, error) {
+	//nolint:wrapcheck
+	return template.New("template").Funcs(Functions).Parse(tmpl)
+}
diff --git a/notification/notifytemplate/repotemplate.go b/notification/notifytemplate/repotemplate.go
new file mode 100644
index 00000000..311f9949
--- /dev/null
+++ b/notification/notifytemplate/repotemplate.go
@@ -0,0 +1,140 @@
+package notifytemplate
+
+import (
+	"context"
+	"strings"
+	"time"
+
+	"github.com/pkg/errors"
+	"golang.org/x/exp/maps"
+
+	"github.com/kopia/kopia/repo"
+	"github.com/kopia/kopia/repo/manifest"
+)
+
+// ManifestType is the manifest type of notification templates.
+const ManifestType = "notificationTemplate"
+
+const templateNameKey = "template"
+
+// TemplateManifest is the manifest of a notification template.
+type TemplateManifest struct {
+	Template string `json:"template"`
+}
+
+// Info returns information about single notification template.
+type Info struct {
+	Name         string     `json:"name"`
+	LastModified *time.Time `json:"lastModified,omitempty"`
+	IsBuiltIn    bool       `json:"isBuiltIn,omitempty"`
+}
+
+// ResolveTemplate resolves a named template from the repository by looking for most-specific defined override
+// and falling back to generic embedded template.
+func ResolveTemplate(ctx context.Context, rep repo.Repository, profileName, baseTemplateName, extension string) (string, error) {
+	candidates := []string{
+		profileName + "." + baseTemplateName + "." + extension,
+		baseTemplateName + "." + extension,
+	}
+
+	for _, c := range candidates {
+		t, found, err := GetTemplate(ctx, rep, c)
+		if err != nil {
+			return "", errors.Wrap(err, "unable to get notification template")
+		}
+
+		if found {
+			return t, nil
+		}
+	}
+
+	return GetEmbeddedTemplate(baseTemplateName + "." + extension)
+}
+
+// GetTemplate returns a named template from the repository.
+func GetTemplate(ctx context.Context, rep repo.Repository, templateName string) (tmpl string, found bool, err error) {
+	manifests, err := rep.FindManifests(ctx, labelsFor(templateName))
+	if err != nil {
+		return "", false, errors.Wrap(err, "unable to find notification template overrides")
+	}
+
+	if len(manifests) > 0 {
+		var tm TemplateManifest
+
+		if _, err := rep.GetManifest(ctx, manifest.PickLatestID(manifests), &tm); err != nil {
+			return "", false, errors.Wrap(err, "unable to get notification template override")
+		}
+
+		return tm.Template, true, nil
+	}
+
+	return "", false, nil
+}
+
+// ListTemplates returns a list of templates.
+func ListTemplates(ctx context.Context, rep repo.Repository, prefix string) ([]Info, error) {
+	infos := map[string]Info{}
+
+	for _, t := range SupportedTemplates() {
+		if !strings.HasPrefix(t, prefix) {
+			continue
+		}
+
+		infos[t] = Info{
+			Name:      t,
+			IsBuiltIn: true,
+		}
+	}
+
+	manifests, err := rep.FindManifests(ctx, map[string]string{
+		manifest.TypeLabelKey: ManifestType,
+	})
+	if err != nil {
+		return nil, errors.Wrap(err, "unable to list notification templates")
+	}
+
+	for _, m := range manifests {
+		name := m.Labels[templateNameKey]
+		if !strings.HasPrefix(name, prefix) {
+			continue
+		}
+
+		infos[name] = Info{
+			Name:         name,
+			IsBuiltIn:    false,
+			LastModified: &m.ModTime,
+		}
+	}
+
+	return maps.Values(infos), nil
+}
+
+// SetTemplate saves a template in the repository.
+func SetTemplate(ctx context.Context, rep repo.RepositoryWriter, templateName, templateText string) error {
+	_, err := rep.ReplaceManifests(ctx, labelsFor(templateName), &TemplateManifest{Template: templateText})
+
+	return errors.Wrap(err, "unable to save notification template")
+}
+
+// ResetTemplate removes a template override from the repository.
+func ResetTemplate(ctx context.Context, rep repo.RepositoryWriter, templateName string) error {
+	entries, err := rep.FindManifests(ctx, labelsFor(templateName))
+	if err != nil {
+		return errors.Wrap(err, "unable to find notification template overrides")
+	}
+
+	for _, e := range entries {
+		if err := rep.DeleteManifest(ctx, e.ID); err != nil {
+			return errors.Wrap(err, "unable to delete notification template override")
+		}
+	}
+
+	return nil
+}
+
+func labelsFor(templateName string) map[string]string {
+	return map[string]string{
+		manifest.TypeLabelKey: ManifestType,
+		templateNameKey:       templateName,
+	}
+}
diff --git a/notification/sender/email/email_sender.go b/notification/sender/email/email_sender.go
new file mode 100644
index 00000000..a5f8bdbf
--- /dev/null
+++ b/notification/sender/email/email_sender.go
@@ -0,0 +1,66 @@
+// Package email provides email notification support.
+package email
+
+import (
+	"context"
+	"fmt"
+	"net/smtp"
+	"strings"
+
+	"github.com/pkg/errors"
+
+	"github.com/kopia/kopia/notification/sender"
+)
+
+// ProviderType defines the type of the email notification provider.
+const ProviderType = "email"
+
+const defaultSMTPPort = 587
+
+type emailProvider struct {
+	opt Options
+}
+
+func (p *emailProvider) Send(ctx context.Context, msg *sender.Message) error {
+	var auth smtp.Auth
+
+	if p.opt.SMTPUsername != "" {
+		auth = smtp.PlainAuth(p.opt.SMTPIdentity, p.opt.SMTPUsername, p.opt.SMTPPassword, p.opt.SMTPServer)
+	}
+
+	var msgPayload []byte
+
+	if p.Format() == sender.FormatHTML {
+		msgPayload = []byte("Subject: " + msg.Subject + "\r\n" + "MIME-version: 1.0;\r\nContent-Type: text/html; charset=\"UTF-8\";\r\n\r\n" + msg.Body)
+	} else {
+		msgPayload = []byte("Subject: " + msg.Subject + "\r\n" + msg.Body)
+	}
+
+	//nolint:wrapcheck
+	return smtp.SendMail(
+		fmt.Sprintf("%v:%d", p.opt.SMTPServer, p.opt.SMTPPort),
+		auth,
+		p.opt.From,
+		strings.Split(p.opt.To, ","),
+		msgPayload)
+}
+
+func (p *emailProvider) Summary() string {
+	return fmt.Sprintf("SMTP server: %q, Mail from: %q Mail to: %q Format: %q", p.opt.SMTPServer, p.opt.From, p.opt.To, p.Format())
+}
+
+func (p *emailProvider) Format() string {
+	return p.opt.Format
+}
+
+func init() {
+	sender.Register(ProviderType, func(ctx context.Context, options *Options) (sender.Provider, error) {
+		if err := options.applyDefaultsAndValidate(); err != nil {
+			return nil, errors.Wrap(err, "invalid notification configuration")
+		}
+
+		return &emailProvider{
+			opt: *options,
+		}, nil
+	})
+}
diff --git a/notification/sender/email/email_sender_options.go b/notification/sender/email/email_sender_options.go
new file mode 100644
index 00000000..18f9fb18
--- /dev/null
+++ b/notification/sender/email/email_sender_options.go
@@ -0,0 +1,67 @@
+package email
+
+import (
+	"github.com/pkg/errors"
+
+	"github.com/kopia/kopia/notification/sender"
+)
+
+// Options defines email notification provider options.
+type Options struct {
+	SMTPServer   string `json:"smtpServer"`
+	SMTPPort     int    `json:"smtpPort"`
+	SMTPIdentity string `json:"smtpIdentity"` // usually empty, most servers use username/password
+	SMTPUsername string `json:"smtpUsername"`
+	SMTPPassword string `json:"smtpPassword"`
+
+	From string `json:"from"`
+	To   string `json:"to"`
+	CC   string `json:"cc"`
+
+	Format string `json:"format"` // format of the message, must be "html" or "md"
+}
+
+// MergeOptions updates the destination options with the source options.
+func MergeOptions(src Options, dst *Options, isUpdate bool) {
+	copyOrMerge(&dst.SMTPServer, src.SMTPServer, isUpdate)
+	copyOrMerge(&dst.SMTPPort, src.SMTPPort, isUpdate)
+	copyOrMerge(&dst.SMTPIdentity, src.SMTPIdentity, isUpdate)
+	copyOrMerge(&dst.SMTPUsername, src.SMTPUsername, isUpdate)
+	copyOrMerge(&dst.SMTPPassword, src.SMTPPassword, isUpdate)
+	copyOrMerge(&dst.From, src.From, isUpdate)
+	copyOrMerge(&dst.To, src.To, isUpdate)
+	copyOrMerge(&dst.CC, src.CC, isUpdate)
+	copyOrMerge(&dst.Format, src.Format, isUpdate)
+}
+
+func (o *Options) applyDefaultsAndValidate() error {
+	if o.SMTPPort == 0 {
+		o.SMTPPort = defaultSMTPPort
+	}
+
+	if o.SMTPServer == "" {
+		return errors.Errorf("SMTP server must be provided")
+	}
+
+	if o.From == "" {
+		return errors.Errorf("From address must be provided")
+	}
+
+	if o.To == "" {
+		return errors.Errorf("To address must be provided")
+	}
+
+	if err := sender.ValidateMessageFormatAndSetDefault(&o.Format, sender.FormatHTML); err != nil {
+		return errors.Wrap(err, "invalid format")
+	}
+
+	return nil
+}
+
+func copyOrMerge[T comparable](dst *T, src T, isUpdate bool) {
+	var defaultT T
+
+	if !isUpdate || src != defaultT {
+		*dst = src
+	}
+}
diff --git a/notification/sender/notification_message.go b/notification/sender/notification_message.go
new file mode 100644
index 00000000..ff95634c
--- /dev/null
+++ b/notification/sender/notification_message.go
@@ -0,0 +1,116 @@
+package sender
+
+import (
+	"bufio"
+	"bytes"
+	"context"
+	"fmt"
+	"io"
+	"sort"
+	"strings"
+
+	"github.com/pkg/errors"
+	"golang.org/x/exp/maps"
+)
+
+// Severity represents the severity of a notification message.
+type Severity int32
+
+// Message represents a notification message.
+type Message struct {
+	Subject  string            `json:"subject"`
+	Headers  map[string]string `json:"headers"`
+	Severity Severity          `json:"severity"`
+	Body     string            `json:"body"`
+}
+
+// ParseMessage parses a notification message string into a Message structure.
+func ParseMessage(ctx context.Context, in io.Reader) (*Message, error) {
+	var bodyLines []string
+
+	// parse headers until we encounter "MarkdownBody:" or an empty line.
+	sr := bufio.NewScanner(in)
+
+	msg := &Message{
+		Headers: map[string]string{},
+	}
+
+	for sr.Scan() {
+		line := sr.Text()
+
+		if line == "" {
+			// no more headers after that
+			break
+		}
+
+		if strings.HasPrefix(line, "Subject:") {
+			msg.Subject = strings.TrimSpace(line[len("Subject:"):])
+			continue
+		}
+
+		// parse headers
+		const numParts = 2
+
+		parts := strings.SplitN(line, ":", numParts)
+		if len(parts) != numParts {
+			log(ctx).Warnw("invalid header line in notification template", "line", line)
+			continue
+		}
+
+		msg.Headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
+	}
+
+	for sr.Scan() {
+		line := sr.Text()
+		bodyLines = append(bodyLines, line)
+	}
+
+	msg.Body = strings.Join(bodyLines, "\n")
+
+	if len(bodyLines) == 0 {
+		return nil, errors.New("no body found in message")
+	}
+
+	return msg, errors.Wrap(sr.Err(), "error reading message")
+}
+
+// ToString returns a string representation of the message.
+func (m Message) ToString() string {
+	var buf bytes.Buffer
+
+	fmt.Fprintf(&buf, "Subject: %v\n", m.Subject)
+
+	headers := maps.Keys(m.Headers)
+
+	sort.Strings(headers)
+
+	for _, k := range headers {
+		fmt.Fprintf(&buf, "%v: %v\n", k, m.Headers[k])
+	}
+
+	fmt.Fprintf(&buf, "\n%v", m.Body)
+
+	return buf.String()
+}
+
+// Supported message formats.
+const (
+	FormatPlainText = "txt"
+	FormatHTML      = "html"
+)
+
+// ValidateMessageFormatAndSetDefault validates message the format and sets the default value if empty.
+func ValidateMessageFormatAndSetDefault(f *string, defaultValue string) error {
+	switch *f {
+	case FormatHTML, FormatPlainText:
+		// ok
+		return nil
+
+	case "":
+		*f = defaultValue
+		return nil
+
+	default:
+		return errors.Errorf("invalid format: %v", *f)
+	}
+}
diff --git a/notification/sender/pushover/pushover_sender.go b/notification/sender/pushover/pushover_sender.go
new file mode 100644
index 00000000..9c6ecde3
--- /dev/null
+++ b/notification/sender/pushover/pushover_sender.go
@@ -0,0 +1,86 @@
+// Package pushover provides pushover notification support.
+package pushover
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+
+	"github.com/pkg/errors"
+
+	"github.com/kopia/kopia/notification/sender"
+)
+
+// ProviderType defines the type of the Pushover notification provider.
+const ProviderType = "pushover"
+
+// defaultPushoverURL is the default URL for the Pushover API.
+const defaultPushoverURL = "https://api.pushover.net/1/messages.json"
+
+type pushoverProvider struct {
+	opt Options
+}
+
+func (p *pushoverProvider) Send(ctx context.Context, msg *sender.Message) error {
+	payload := map[string]string{
+		"token":   p.opt.AppToken,
+		"user":    p.opt.UserKey,
+		"message": msg.Subject + "\n\n" + msg.Body,
+	}
+
+	if p.Format() == "html" {
+		payload["html"] = "1"
+	}
+
+	targetURL := defaultPushoverURL
+	if p.opt.Endpoint != "" {
+		targetURL = p.opt.Endpoint
+	}
+
+	body, err := json.Marshal(payload)
+	if err != nil {
+		return errors.Wrap(err, "error preparing pushover notification")
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
+	if err != nil {
+		return errors.Wrap(err, "error preparing pushover notification")
+	}
+
+	req.Header.Set("Content-Type", "application/json")
+
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return errors.Wrap(err, "error sending pushover notification")
+	}
+
+	defer resp.Body.Close() //nolint:errcheck
+
+	if resp.StatusCode != http.StatusOK {
+		return errors.Errorf("error sending pushover notification: %v", resp.Status)
+	}
+
+	return nil
+}
+
+func (p *pushoverProvider) Summary() string {
+	return fmt.Sprintf("Pushover user %q app %q format %q", p.opt.UserKey, p.opt.AppToken, p.Format())
+}
+
+func (p *pushoverProvider) Format() string {
+	return p.opt.Format
+}
+
+func init() {
+	sender.Register(ProviderType, func(ctx context.Context, options *Options) (sender.Provider, error) {
+		if err := options.ApplyDefaultsAndValidate(ctx); err != nil {
+			return nil, errors.Wrap(err, "invalid notification configuration")
+		}
+
+		return &pushoverProvider{
+			opt: *options,
+		}, nil
+	})
+}
diff --git a/notification/sender/pushover/pushover_sender_options.go b/notification/sender/pushover/pushover_sender_options.go
new file mode 100644
index 00000000..035e11f4
--- /dev/null
+++ b/notification/sender/pushover/pushover_sender_options.go
@@ -0,0 +1,49 @@
+package pushover
+
+import (
+	"context"
+
+	"github.com/pkg/errors"
+
+	"github.com/kopia/kopia/notification/sender"
+)
+
+// Options defines Pushover notification sender options.
+type Options struct {
+	AppToken string `json:"appToken"`
+	UserKey  string `json:"userKey"`
+	Format   string `json:"format"` // format of the message, must be "html" or "md"
+
+	Endpoint string `json:"endpoint,omitempty"` // override the default endpoint for testing
+}
+
+// ApplyDefaultsAndValidate applies default values and validates the configuration.
+func (o *Options) ApplyDefaultsAndValidate(ctx context.Context) error {
+	if o.AppToken == "" {
+		return errors.Errorf("App Token must be provided")
+	}
+
+	if o.UserKey == "" {
+		return errors.Errorf("User Key must be provided")
+	}
+
+	if err := sender.ValidateMessageFormatAndSetDefault(&o.Format, sender.FormatPlainText); err != nil {
+		return errors.Wrap(err, "invalid format")
+	}
+
+	return nil
+}
+
+// MergeOptions updates the destination options with the source options.
+func MergeOptions(src Options, dst *Options, isUpdate bool) {
+	copyOrMerge(&dst.AppToken, src.AppToken, isUpdate)
+	copyOrMerge(&dst.UserKey, src.UserKey, isUpdate)
+}
+
+func copyOrMerge[T comparable](dst *T, src T, isUpdate bool) {
+	var defaultT T
+
+	if !isUpdate || src != defaultT {
+		*dst = src
+	}
+}
diff --git a/notification/sender/sender.go b/notification/sender/sender.go
new file mode 100644
index 00000000..0e8bd6d0
--- /dev/null
+++ b/notification/sender/sender.go
@@ -0,0 +1,87 @@
+// Package sender provides a common interface for sending notifications.
+package sender
+
+import (
+	"context"
+	"encoding/json"
+
+	"github.com/pkg/errors"
+
+	"github.com/kopia/kopia/repo/logging"
+)
+
+var log = logging.Module("notification/sender")
+
+// Provider is an interface implemented by all notification providers.
+type Provider interface {
+	Send(ctx context.Context, msg *Message) error
+
+	// Format returns the format of the message body that the provider supports, either "html" or "md", some providers will support both.
+	Format() string
+
+	// Summary returns a human-readable summary of the provider configuration.
+	Summary() string
+}
+
+// Sender is an interface implemented by all notification senders that also provide a profile name.
+type Sender interface {
+	Provider
+
+	ProfileName() string
+}
+
+// Factory is a function that creates a new instance of a notification sender with a
+// given context and options.
+type Factory[T any] func(ctx context.Context, options T) (Provider, error)
+
+//nolint:gochecknoglobals
+var (
+	allSenders     = map[Method]Factory[any]{}
+	defaultOptions = map[Method]any{}
+)
+
+type senderWrapper struct {
+	Provider
+	profileName string
+}
+
+func (s senderWrapper) ProfileName() string {
+	return s.profileName
+}
+
+// GetSender returns a new instance of a sender with a given name and options.
+func GetSender(ctx context.Context, profile string, method Method, jsonOptions any) (Sender, error) {
+	factory := allSenders[method]
+	if factory == nil {
+		return nil, errors.Errorf("unknown sender: %v", method)
+	}
+
+	sp, err := factory(ctx, jsonOptions)
+	if err != nil {
+		return nil, errors.Wrap(err, "unable to create sender")
+	}
+
+	return senderWrapper{sp, profile}, nil
+}
+
+// Register registers a new provider with a given name and factory function.
+func Register[T any](method Method, p Factory[*T]) {
+	var defT T
+
+	defaultOptions[method] = defT
+
+	allSenders[method] = func(ctx context.Context, jsonOptions any) (Provider, error) {
+		typedOptions := defT
+
+		v, err := json.Marshal(jsonOptions)
+		if err != nil {
+			return nil, errors.Wrap(err, "unable to marshal options")
+		}
+
+		if err := json.Unmarshal(v, &typedOptions); err != nil {
+			return nil, errors.Wrap(err, "unable to unmarshal options")
+		}
+
+		return p(ctx, &typedOptions)
+	}
+}
diff --git a/notification/sender/sender_config.go b/notification/sender/sender_config.go
new file mode 100644
index 00000000..9855c41d
--- /dev/null
+++ b/notification/sender/sender_config.go
@@ -0,0 +1,67 @@
+package sender
+
+import (
+	"encoding/json"
+
+	"github.com/pkg/errors"
+)
+
+// Method represents the configuration of a Sender.
+type Method string
+
+// MethodConfig represents JSON-serializable configuration of a notification method and parameters.
+type MethodConfig struct {
+	Type   Method
+	Config any
+}
+
+// UnmarshalJSON parses the JSON-encoded notification method configuration into MethodInfo.
+func (c *MethodConfig) UnmarshalJSON(b []byte) error {
+	raw := struct {
+		Type Method          `json:"type"`
+		Data json.RawMessage `json:"config"`
+	}{}
+
+	if err := json.Unmarshal(b, &raw); err != nil {
+		return errors.Wrap(err, "error unmarshaling connection info JSON")
+	}
+
+	c.Type = raw.Type
+
+	if f := allSenders[raw.Type]; f == nil {
+		return errors.Errorf("sender type '%v' not registered", raw.Type)
+	}
+
+	c.Config = defaultOptions[raw.Type]
+	if err := json.Unmarshal(raw.Data, &c.Config); err != nil {
+		return errors.Wrap(err, "unable to unmarshal config")
+	}
+
+	return nil
+}
+
+// Options unmarshals the configuration into the provided structure.
+func (c MethodConfig) Options(result any) error {
+	b, err := json.Marshal(c.Config)
+	if err != nil {
+		return errors.Wrap(err, "unable to marshal config")
+	}
+
+	if err := json.Unmarshal(b, result); err != nil {
+		return errors.Wrap(err, "unable to unmarshal config")
+	}
+
+	return nil
+}
+
+// MarshalJSON returns JSON-encoded notification method configuration.
+func (c MethodConfig) MarshalJSON() ([]byte, error) {
+	//nolint:wrapcheck
+	return json.Marshal(struct {
+		Type Method      `json:"type"`
+		Data interface{} `json:"config"`
+	}{
+		Type: c.Type,
+		Data: c.Config,
+	})
+}
diff --git a/notification/sender/webhook/webhook_sender.go b/notification/sender/webhook/webhook_sender.go
new file mode 100644
index 00000000..fc78f533
--- /dev/null
+++ b/notification/sender/webhook/webhook_sender.go
@@ -0,0 +1,72 @@
+// Package webhook provides webhook notification support.
+package webhook
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/pkg/errors"
+
+	"github.com/kopia/kopia/notification/sender"
+)
+
+// ProviderType defines the type of the Webhook notification provider.
+const ProviderType = "webhook"
+
+type webhookProvider struct {
+	opt Options
+}
+
+func (p *webhookProvider) Send(ctx context.Context, msg *sender.Message) error {
+	targetURL := p.opt.Endpoint
+	method := p.opt.Method
+
+	body := bytes.NewReader([]byte(msg.Body))
+
+	req, err := http.NewRequestWithContext(ctx, method, targetURL, body)
+	if err != nil {
+		return errors.Wrap(err, "error preparing notification")
+	}
+
+	req.Header.Set("Subject", msg.Subject)
+
+	// copy headers from message
+	for k, v := range msg.Headers {
+		req.Header.Set(k, v)
+	}
+
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return errors.Wrap(err, "error sending webhook notification")
+	}
+
+	defer resp.Body.Close() //nolint:errcheck
+
+	if resp.StatusCode != http.StatusOK {
+		return errors.Errorf("error sending webhook notification: %v", resp.Status)
+	}
+
+	return nil
+}
+
+func (p *webhookProvider) Summary() string {
+	return fmt.Sprintf("Webhook %v %v Format %q", p.opt.Method, p.opt.Endpoint, p.Format())
+}
+
+func (p *webhookProvider) Format() string {
+	return p.opt.Format
+}
+
+func init() {
+	sender.Register(ProviderType, func(ctx context.Context, options *Options) (sender.Provider, error) {
+		if err := options.ApplyDefaultsAndValidate(ctx); err != nil {
+			return nil, errors.Wrap(err, "invalid notification configuration")
+		}
+
+		return &webhookProvider{
+			opt: *options,
+		}, nil
+	})
+}
diff --git a/notification/sender/webhook/webhook_sender_options.go b/notification/sender/webhook/webhook_sender_options.go
new file mode 100644
index 00000000..18d1b904
--- /dev/null
+++ b/notification/sender/webhook/webhook_sender_options.go
@@ -0,0 +1,54 @@
+package webhook
+
+import (
+	"context"
+	"net/url"
+
+	"github.com/pkg/errors"
+
+	"github.com/kopia/kopia/notification/sender"
+)
+
+// Options defines Webhook sender options.
+type Options struct {
+	Endpoint string `json:"endpoint"`
+	Method   string `json:"method"`
+	Format   string `json:"format"`
+}
+
+// ApplyDefaultsAndValidate applies default values and validates the configuration.
+func (o *Options) ApplyDefaultsAndValidate(ctx context.Context) error {
+	if o.Method == "" {
+		o.Method = "POST"
+	}
+
+	if err := sender.ValidateMessageFormatAndSetDefault(&o.Format, sender.FormatPlainText); err != nil {
+		return errors.Wrap(err, "invalid format")
+	}
+
+	u, err := url.ParseRequestURI(o.Endpoint)
+	if err != nil {
+		return errors.Errorf("invalid endpoint")
+	}
+
+	if u.Scheme != "http" && u.Scheme != "https" {
+		return errors.Errorf("invalid endpoint scheme, must be http:// or https://")
+	}
+
+	return nil
+}
+
+// MergeOptions updates the destination options with the source options.
+func MergeOptions(src Options, dst *Options, isUpdate bool) {
+	copyOrMerge(&dst.Endpoint, src.Endpoint, isUpdate)
+	copyOrMerge(&dst.Method, src.Method, isUpdate)
+	copyOrMerge(&dst.Format, src.Format, isUpdate)
+}
+
+func copyOrMerge[T comparable](dst *T, src T, isUpdate bool) {
+	var defaultT T
+
+	if !isUpdate || src != defaultT {
+		*dst = src
+	}
+}
diff --git a/repo/grpc_repository_client.go b/repo/grpc_repository_client.go
index cf4c27cf..310ed506 100644
--- a/repo/grpc_repository_client.go
+++ b/repo/grpc_repository_client.go
@@ -468,6 +468,38 @@ func (r *grpcInnerSession) ApplyRetentionPolicy(ctx context.Context, sourcePath
 	return nil, errNoSessionResponse()
 }
 
+func (r *grpcRepositoryClient) SendNotification(ctx context.Context, templateName string, templateDataJSON []byte, importance int32) error {
+	_, err := maybeRetry(ctx, r, func(ctx context.Context, sess *grpcInnerSession) (struct{}, error) {
+		return sess.SendNotification(ctx, templateName, templateDataJSON, importance)
+	})
+
+	return err
+}
+
+var _ RemoteNotifications = (*grpcRepositoryClient)(nil)
+
+func (r *grpcInnerSession) SendNotification(ctx context.Context, templateName string, templateDataJSON []byte, severity int32) (struct{}, error) {
+	for resp := range r.sendRequest(ctx, &apipb.SessionRequest{
+		Request: &apipb.SessionRequest_SendNotification{
+			SendNotification: &apipb.SendNotificationRequest{
+				TemplateName: templateName,
+				EventArgs:    templateDataJSON,
+				Severity:     severity,
+			},
+		},
+	}) {
+		switch resp.GetResponse().(type) {
+		case *apipb.SessionResponse_SendNotification:
+			return struct{}{}, nil
+
+		default:
+			return struct{}{}, unhandledSessionResponse(resp)
+		}
+	}
+
+	return struct{}{}, errNoSessionResponse()
+}
+
 func (r *grpcRepositoryClient) Time() time.Time {
 	return clock.Now()
 }
diff --git a/repo/manifest/manifest_manager.go b/repo/manifest/manifest_manager.go
index 7e00762f..cb53935f 100644
--- a/repo/manifest/manifest_manager.go
+++ b/repo/manifest/manifest_manager.go
@@ -117,7 +117,7 @@ func (m *Manager) Get(ctx context.Context, id ID, data interface{}) (*EntryMetad
 
 	if data != nil {
 		if err := json.Unmarshal([]byte(e.Content), data); err != nil {
-			return nil, errors.Wrapf(err, "unable to unmashal %q", id)
+			return nil, errors.Wrapf(err, "unable to unmarshal %q", id)
 		}
 	}
 
diff --git a/repo/repository.go b/repo/repository.go
index e9451eda..4b7bb8e3 100644
--- a/repo/repository.go
+++ b/repo/repository.go
@@ -61,6 +61,11 @@ type RemoteRetentionPolicy interface {
 	ApplyRetentionPolicy(ctx context.Context, sourcePath string, reallyDelete bool) ([]manifest.ID, error)
 }
 
+// RemoteNotifications is an interface implemented by repository clients that support remote notifications.
+type RemoteNotifications interface {
+	SendNotification(ctx context.Context, templateName string, templateDataJSON []byte, severity int32) error
+}
+
 // DirectRepository provides additional low-level repository functionality.
 //
 //nolint:interfacebloat


Test patch:
diff --git a/cli/command_notification_configure_testsender.go b/cli/command_notification_configure_testsender.go
new file mode 100644
index 00000000..fcbc6755
--- /dev/null
+++ b/cli/command_notification_configure_testsender.go
@@ -0,0 +1,21 @@
+package cli
+
+import (
+	"github.com/kopia/kopia/notification/sender"
+	"github.com/kopia/kopia/notification/sender/testsender"
+)
+
+type commandNotificationConfigureTestSender struct {
+	common commonNotificationOptions
+
+	opt testsender.Options
+}
+
+func (c *commandNotificationConfigureTestSender) setup(svc appServices, parent commandParent) {
+	cmd := parent.Command("testsender", "Testing notification.")
+
+	c.common.setup(svc, cmd)
+	cmd.Flag("format", "Format of the message").EnumVar(&c.opt.Format, sender.FormatHTML, sender.FormatPlainText)
+
+	cmd.Action(configureNotificationAction(svc, &c.common, testsender.ProviderType, &c.opt, testsender.MergeOptions))
+}
diff --git a/cli/command_notification_profile_internal_test.go b/cli/command_notification_profile_internal_test.go
new file mode 100644
index 00000000..2194baba
--- /dev/null
+++ b/cli/command_notification_profile_internal_test.go
@@ -0,0 +1,34 @@
+package cli
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/kopia/kopia/internal/repotesting"
+	"github.com/kopia/kopia/notification/notifyprofile"
+	"github.com/kopia/kopia/notification/sender"
+)
+
+func TestNotificationProfileAutocomplete(t *testing.T) {
+	t.Parallel()
+
+	var a notificationProfileFlag
+
+	ctx, env := repotesting.NewEnvironment(t, repotesting.FormatNotImportant)
+
+	require.Empty(t, a.listNotificationProfiles(ctx, env.Repository))
+	require.NoError(t, notifyprofile.SaveProfile(ctx, env.RepositoryWriter, notifyprofile.Config{
+		ProfileName: "test-profile",
+		MethodConfig: sender.MethodConfig{
+			Type:   "email",
+			Config: map[string]string{},
+		},
+	}))
+	require.NoError(t, env.RepositoryWriter.Flush(ctx))
+
+	require.Contains(t, a.listNotificationProfiles(ctx, env.Repository), "test-profile")
+
+	a.profileName = "no-such-profile"
+	require.Empty(t, a.listNotificationProfiles(ctx, env.Repository))
+}
diff --git a/cli/command_notification_profile_test.go b/cli/command_notification_profile_test.go
new file mode 100644
index 00000000..fc74a3ba
--- /dev/null
+++ b/cli/command_notification_profile_test.go
@@ -0,0 +1,60 @@
+package cli_test
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/kopia/kopia/internal/testutil"
+	"github.com/kopia/kopia/notification/notifyprofile"
+	"github.com/kopia/kopia/tests/testenv"
+)
+
+func TestNotificationProfile(t *testing.T) {
+	t.Parallel()
+
+	e := testenv.NewCLITest(t, testenv.RepoFormatNotImportant, testenv.NewInProcRunner(t))
+
+	defer e.RunAndExpectSuccess(t, "repo", "disconnect")
+
+	e.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", e.RepoDir)
+
+	var profiles []notifyprofile.Summary
+
+	testutil.MustParseJSONLines(t, e.RunAndExpectSuccess(t, "notification", "profile", "list", "--json"), &profiles)
+	require.Empty(t, profiles)
+
+	// setup a profile
+	e.RunAndExpectSuccess(t, "notification", "profile", "configure", "testsender", "--profile-name=mywebhook")
+	testutil.MustParseJSONLines(t, e.RunAndExpectSuccess(t, "notification", "profile", "list", "--json"), &profiles)
+	require.Len(t, profiles, 1)
+	require.Equal(t, "testsender", profiles[0].Type)
+
+	// nothing is sent so far
+	require.Empty(t, e.NotificationsSent())
+
+	// now send a test message
+	e.RunAndExpectSuccess(t, "notification", "profile", "test", "--profile-name=mywebhook")
+
+	// make sure we received the test request
+	require.Len(t, e.NotificationsSent(), 1)
+	require.Contains(t, e.NotificationsSent()[0].Body, "If you received this, your notification configuration")
+
+	// define another profile
+	e.RunAndExpectSuccess(t, "notification", "profile", "configure", "testsender", "--profile-name=myotherwebhook", "--min-severity=warning")
+
+	lines := e.RunAndExpectSuccess(t, "notification", "profile", "list")
+
+	require.Contains(t, lines, "Profile \"mywebhook\" Type \"testsender\" Minimum Severity: report")
+	require.Contains(t, lines, "Profile \"myotherwebhook\" Type \"testsender\" Minimum Severity: warning")
+
+	// delete non-existent profile does not fail
+	e.RunAndExpectSuccess(t, "notification", "profile", "delete", "--profile-name=unknown")
+
+	// delete existing profiles
+	e.RunAndExpectSuccess(t, "notification", "profile", "delete", "--profile-name=myotherwebhook")
+	e.RunAndExpectSuccess(t, "notification", "profile", "delete", "--profile-name=mywebhook")
+
+	// no profiles left
+	require.Empty(t, e.RunAndExpectSuccess(t, "notification", "profile", "list"))
+}
diff --git a/cli/command_notification_template_internal_test.go b/cli/command_notification_template_internal_test.go
new file mode 100644
index 00000000..1df137b9
--- /dev/null
+++ b/cli/command_notification_template_internal_test.go
@@ -0,0 +1,29 @@
+package cli
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/kopia/kopia/internal/repotesting"
+)
+
+func TestNotificationTemplatesAutocomplete(t *testing.T) {
+	t.Parallel()
+
+	var a notificationTemplateNameArg
+
+	ctx, env := repotesting.NewEnvironment(t, repotesting.FormatNotImportant)
+
+	require.Contains(t,
+		a.listNotificationTemplates(ctx, env.Repository),
+		"test-notification.txt")
+
+	a.templateName = "no-such-prefix"
+	require.Empty(t, a.listNotificationTemplates(ctx, env.Repository))
+
+	a.templateName = "test-notif"
+	require.Contains(t,
+		a.listNotificationTemplates(ctx, env.Repository),
+		"test-notification.txt")
+}
diff --git a/cli/command_notification_template_test.go b/cli/command_notification_template_test.go
new file mode 100644
index 00000000..19e38775
--- /dev/null
+++ b/cli/command_notification_template_test.go
@@ -0,0 +1,132 @@
+package cli_test
+
+import (
+	"context"
+	"os"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/kopia/kopia/internal/editor"
+	"github.com/kopia/kopia/notification/notifytemplate"
+	"github.com/kopia/kopia/tests/testenv"
+)
+
+func TestNotificationTemplates(t *testing.T) {
+	t.Parallel()
+
+	runner := testenv.NewInProcRunner(t)
+	e := testenv.NewCLITest(t, testenv.RepoFormatNotImportant, runner)
+	defer e.RunAndExpectSuccess(t, "repo", "disconnect")
+
+	e.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", e.RepoDir)
+
+	defaultTemplates := e.RunAndExpectSuccess(t, "notification", "template", "list")
+	require.Len(t, defaultTemplates[1:], len(notifytemplate.SupportedTemplates()))
+
+	// initially all templates are built-in
+	for _, line := range defaultTemplates[1:] {
+		require.Contains(t, line, "<built-in>")
+	}
+
+	// override 'test-notification.txt' template from STDIN and verify.
+	runner.SetNextStdin(strings.NewReader("Subject: test-template-subject\n\ntest-template-body1\ntest-template-body2\n"))
+	e.RunAndExpectSuccess(t, "notification", "template", "set", "test-notification.txt", "--from-stdin")
+
+	verifyTemplateContents(t, e, "test-notification.txt", []string{
+		"Subject: test-template-subject",
+		"",
+		"test-template-body1",
+		"test-template-body2",
+	})
+
+	// make sure it shows up as <customized>
+	verifyHasLine(t, e.RunAndExpectSuccess(t, "notification", "template", "list"), func(s string) bool {
+		return strings.Contains(s, "test-notification.txt") && strings.Contains(s, "<customized>")
+	})
+
+	// reset 'test-notification.txt' template and verify.
+	e.RunAndExpectSuccess(t, "notification", "template", "remove", "test-notification.txt")
+
+	// make sure it shows up as <built-in>
+	verifyHasLine(t, e.RunAndExpectSuccess(t, "notification", "template", "list"), func(s string) bool {
+		return strings.Contains(s, "test-notification.txt") && strings.Contains(s, "<built-in>")
+	})
+
+	// now the same using external file
+	td := t.TempDir()
+	fname := td + "/template.md"
+
+	// no such file
+	e.RunAndExpectFailure(t, "notification", "template", "set", "test-notification.txt", "--from-file="+fname)
+
+	os.WriteFile(fname, []byte("Subject: test-template-subject\n\ntest-template-body3\ntest-template-body4\n"), 0o600)
+	e.RunAndExpectSuccess(t, "notification", "template", "set", "test-notification.txt", "--from-file="+fname)
+	verifyTemplateContents(t, e, "test-notification.txt", []string{
+		"Subject: test-template-subject",
+		"",
+		"test-template-body3",
+		"test-template-body4",
+	})
+
+	// override editor for the next part
+	oldEditor := editor.EditFile
+	defer func() { editor.EditFile = oldEditor }()
+
+	invokedSecond := false
+
+	// when editor is first invoked, it will show the old template
+	// try setting the template to an invalid value and verify that the editor is invoked again.
+	editor.EditFile = func(ctx context.Context, filename string) error {
+		b, err := os.ReadFile(filename)
+		require.NoError(t, err)
+
+		// verify we got the old version of the template
+		require.Equal(t, "Subject: test-template-subject\n\ntest-template-body3\ntest-template-body4\n", string(b))
+
+		// write an invalid template that fails to parse
+		os.WriteFile(filename, []byte("Subject: test-template-subject\n\ntest-template-body5 {{\ntest-template-body6\n"), 0o600)
+
+		// editor will be invoked again
+		editor.EditFile = func(ctx context.Context, filename string) error {
+			invokedSecond = true
+
+			// this time we write the corrected template
+			os.WriteFile(filename, []byte("Subject: test-template-subject\n\ntest-template-body5\ntest-template-body6\n"), 0o600)
+
+			return nil
+		}
+		return nil
+	}
+
+	e.RunAndExpectSuccess(t, "notification", "template", "set", "test-notification.txt", "--editor")
+
+	verifyTemplateContents(t, e, "test-notification.txt", []string{
+		"Subject: test-template-subject",
+		"",
+		"test-template-body5",
+		"test-template-body6",
+	})
+
+	require.True(t, invokedSecond)
+}
+
+func verifyTemplateContents(t *testing.T, e *testenv.CLITest, templateName string, expectedLines []string) {
+	t.Helper()
+
+	lines := e.RunAndExpectSuccess(t, "notification", "template", "show", ""+templateName)
+	require.Equal(t, expectedLines, lines)
+}
+
+func verifyHasLine(t *testing.T, lines []string, ok func(s string) bool) {
+	t.Helper()
+
+	for _, l := range lines {
+		if ok(l) {
+			return
+		}
+	}
+
+	t.Errorf("output line meeting given condition was not found: %v", lines)
+}
diff --git a/internal/server/server_test.go b/internal/server/server_test.go
index 9d3f33ac..321a2947 100644
--- a/internal/server/server_test.go
+++ b/internal/server/server_test.go
@@ -4,6 +4,8 @@
 	"context"
 	"io"
 	"net/http"
+	"net/http/httptest"
+	"sync/atomic"
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
@@ -14,6 +16,11 @@
 	"github.com/kopia/kopia/internal/repotesting"
 	"github.com/kopia/kopia/internal/servertesting"
 	"github.com/kopia/kopia/internal/testutil"
+	"github.com/kopia/kopia/notification"
+	"github.com/kopia/kopia/notification/notifyprofile"
+	"github.com/kopia/kopia/notification/notifytemplate"
+	"github.com/kopia/kopia/notification/sender"
+	"github.com/kopia/kopia/notification/sender/webhook"
 	"github.com/kopia/kopia/repo"
 	"github.com/kopia/kopia/repo/content"
 	"github.com/kopia/kopia/repo/manifest"
@@ -50,6 +57,7 @@ func TestServer(t *testing.T) {
 	defer rep.Close(ctx)
 
 	remoteRepositoryTest(ctx, t, rep)
+	remoteRepositoryNotificationTest(t, ctx, rep, env.RepositoryWriter)
 }
 
 func TestGRPCServer_AuthenticationError(t *testing.T) {
@@ -255,6 +263,54 @@ func remoteRepositoryTest(ctx context.Context, t *testing.T, rep repo.Repository
 	mustPrefetchObjects(ctx, t, rep, result)
 }
 
+//nolint:thelper
+func remoteRepositoryNotificationTest(t *testing.T, ctx context.Context, rep repo.Repository, rw repo.RepositoryWriter) {
+	require.Implements(t, (*repo.RemoteNotifications)(nil), rep)
+
+	mux := http.NewServeMux()
+
+	var numRequestsReceived atomic.Int32
+
+	mux.HandleFunc("/some-path", func(w http.ResponseWriter, r *http.Request) {
+		numRequestsReceived.Add(1)
+	})
+
+	server := httptest.NewServer(mux)
+	defer server.Close()
+
+	require.NoError(t, notifyprofile.SaveProfile(ctx, rw, notifyprofile.Config{
+		ProfileName: "my-profile",
+		MethodConfig: sender.MethodConfig{
+			Type: "webhook",
+			Config: &webhook.Options{
+				Endpoint: server.URL + "/some-path",
+				Method:   "POST",
+			},
+		},
+	}))
+	require.NoError(t, rw.Flush(ctx))
+
+	notification.Send(ctx, rep, notifytemplate.TestNotification, nil, notification.SeverityError)
+	require.Equal(t, int32(1), numRequestsReceived.Load())
+
+	// another webhook which fails
+
+	require.NoError(t, notifyprofile.SaveProfile(ctx, rw, notifyprofile.Config{
+		ProfileName: "my-profile",
+		MethodConfig: sender.MethodConfig{
+			Type: "webhook",
+			Config: &webhook.Options{
+				Endpoint: server.URL + "/some-nonexistent-path",
+				Method:   "POST",
+			},
+		},
+	}))
+
+	require.NoError(t, rw.Flush(ctx))
+	notification.Send(ctx, rep, notifytemplate.TestNotification, nil, notification.SeverityError)
+	require.Equal(t, int32(1), numRequestsReceived.Load())
+}
+
 func mustWriteObject(ctx context.Context, t *testing.T, w repo.RepositoryWriter, data []byte) object.ID {
 	t.Helper()
 
diff --git a/notification/notifytemplate/test-notification.html b/notification/notifytemplate/test-notification.html
new file mode 100644
index 00000000..d8823c65
--- /dev/null
+++ b/notification/notifytemplate/test-notification.html
@@ -0,0 +1,9 @@
+Subject: Test notification from Kopia at {{ .EventTime }}
+
+<p>This is a test notification from Kopia.</p>
+
+<li>Kopia Version: <b>{{ .KopiaBuildVersion }}</b></li>
+<li>Build Info:    <b>{{ .KopiaBuildInfo }}</b></li>
+<li>Github Repo:   <b>{{ .KopiaRepo }}</b></li>
+
+If you received this, your notification configuration on {{ .Hostname }} is correct.
\ No newline at end of file
diff --git a/notification/notifytemplate/test-notification.txt b/notification/notifytemplate/test-notification.txt
new file mode 100644
index 00000000..e364e269
--- /dev/null
+++ b/notification/notifytemplate/test-notification.txt
@@ -0,0 +1,9 @@
+Subject: Test notification from Kopia at {{ .EventTime }}
+
+This is a test notification from Kopia.
+
+- Kopia Version: **{{ .KopiaBuildVersion }}**
+- Build Info:    **{{ .KopiaBuildInfo }}**
+- Github Repo:   **{{ .KopiaRepo }}**
+
+If you received this, your notification configuration on {{ .Hostname }} is correct.
\ No newline at end of file
diff --git a/notification/sender/email/email_sender_test.go b/notification/sender/email/email_sender_test.go
new file mode 100644
index 00000000..363f8cd2
--- /dev/null
+++ b/notification/sender/email/email_sender_test.go
@@ -0,0 +1,128 @@
+package email_test
+
+import (
+	"testing"
+	"time"
+
+	smtpmock "github.com/mocktools/go-smtp-mock/v2"
+	"github.com/stretchr/testify/require"
+
+	"github.com/kopia/kopia/internal/testlogging"
+	"github.com/kopia/kopia/notification/sender"
+	"github.com/kopia/kopia/notification/sender/email"
+)
+
+func TestEmailProvider(t *testing.T) {
+	ctx := testlogging.Context(t)
+
+	srv := smtpmock.New(smtpmock.ConfigurationAttr{
+		LogServerActivity: true,
+		LogToStdout:       true,
+	})
+	require.NoError(t, srv.Start())
+	defer srv.Stop()
+
+	p, err := sender.GetSender(ctx, "my-profile", "email", &email.Options{
+		SMTPServer: "localhost",
+		SMTPPort:   srv.PortNumber(),
+		From:       "some-user@example.com",
+		To:         "another-user@example.com",
+		Format:     sender.FormatHTML,
+	})
+	require.NoError(t, err)
+
+	require.Equal(t, "SMTP server: \"localhost\", Mail from: \"some-user@example.com\" Mail to: \"another-user@example.com\" Format: \"html\"", p.Summary())
+
+	require.NoError(t, p.Send(ctx, &sender.Message{Subject: "Test", Body: `
+This is a test.
+
+* one
+* two
+* three
+
+# Header
+## Subheader
+
+- a
+- b
+- c`}))
+
+	require.Eventually(t, func() bool {
+		return len(srv.Messages()) == 1
+	}, 10*time.Second, time.Second)
+	require.Len(t, srv.Messages(), 1)
+	msg := srv.Messages()[0]
+
+	require.Equal(t, "Subject: Test\r\nMIME-version: 1.0;\r\nContent-Type: text/html; charset=\"UTF-8\";\r\n\r\n\r\nThis is a test.\r\n\r\n* one\r\n* two\r\n* three\r\n\r\n# Header\r\n## Subheader\r\n\r\n- a\r\n- b\r\n- c\r\n", msg.MsgRequest())
+}
+
+func TestEmailProvider_AUTH(t *testing.T) {
+	ctx := testlogging.Context(t)
+
+	srv := smtpmock.New(smtpmock.ConfigurationAttr{
+		LogServerActivity: true,
+		LogToStdout:       true,
+	})
+	require.NoError(t, srv.Start())
+	defer srv.Stop()
+
+	p2, err := sender.GetSender(ctx, "my-profile", "email", &email.Options{
+		SMTPServer:   "localhost",
+		SMTPPort:     srv.PortNumber(),
+		From:         "some-user@example.com",
+		To:           "another-user@example.com",
+		SMTPIdentity: "some-identity",
+		SMTPUsername: "some-username",
+		SMTPPassword: "some-password",
+		CC:           "cc1@example.com",
+	})
+	require.NoError(t, err)
+	require.ErrorContains(t,
+		p2.Send(ctx, &sender.Message{Subject: "Test", Body: "test"}),
+		"smtp: server doesn't support AUTH")
+}
+
+func TestEmailProvider_Invalid(t *testing.T) {
+	ctx := testlogging.Context(t)
+
+	cases := []struct {
+		opt       email.Options
+		wantError string
+	}{
+		{opt: email.Options{}, wantError: "SMTP server must be provided"},
+		{opt: email.Options{SMTPServer: "some.server.com"}, wantError: "From address must be provided"},
+		{opt: email.Options{SMTPServer: "some.server.com", From: "some@example.com"}, wantError: "To address must be provided"},
+	}
+
+	for _, tc := range cases {
+		_, err := sender.GetSender(ctx, "my-profile", "email", &tc.opt)
+		require.ErrorContains(t, err, tc.wantError)
+	}
+}
+
+func TestMergeOptions(t *testing.T) {
+	var dst email.Options
+
+	email.MergeOptions(email.Options{
+		SMTPServer: "server1",
+		From:       "from1",
+	}, &dst, false)
+
+	require.Equal(t, "server1", dst.SMTPServer)
+	require.Equal(t, "from1", dst.From)
+
+	email.MergeOptions(email.Options{
+		From: "user2",
+	}, &dst, true)
+
+	require.Equal(t, "server1", dst.SMTPServer)
+	require.Equal(t, "user2", dst.From)
+
+	email.MergeOptions(email.Options{
+		SMTPServer: "app2",
+		From:       "user2",
+	}, &dst, true)
+
+	require.Equal(t, "app2", dst.SMTPServer)
+	require.Equal(t, "user2", dst.From)
+}
diff --git a/notification/sender/notification_message_test.go b/notification/sender/notification_message_test.go
new file mode 100644
index 00000000..ed3676f9
--- /dev/null
+++ b/notification/sender/notification_message_test.go
@@ -0,0 +1,116 @@
+package sender_test
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/kopia/kopia/internal/testlogging"
+	"github.com/kopia/kopia/notification/sender"
+)
+
+func TestParseMessage(t *testing.T) {
+	testCases := []struct {
+		name     string
+		input    string
+		expected *sender.Message
+	}{
+		{
+			name: "ValidMessage",
+			input: `Subject: Test Subject
+Header1: Value1
+InvalidHeaderLine will be dropped
+Header2: Value2
+
+This is the body of the message.`,
+			expected: &sender.Message{
+				Subject: "Test Subject",
+				Headers: map[string]string{
+					"Header1": "Value1",
+					"Header2": "Value2",
+				},
+				Body: "This is the body of the message.",
+			},
+		},
+		{
+			name: "ValidMessage",
+			input: `Subject: Test Subject
+Header1: Value1
+InvalidHeaderLine will be dropped
+Header2: Value2
+
+This is the body of the message.`,
+			expected: &sender.Message{
+				Subject: "Test Subject",
+				Headers: map[string]string{
+					"Header1": "Value1",
+					"Header2": "Value2",
+				},
+				Body: "This is the body of the message.",
+			},
+		}, // Add more test cases here...
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			reader := strings.NewReader(tc.input)
+			ctx := testlogging.Context(t)
+			actual, err := sender.ParseMessage(ctx, reader)
+
+			require.NoError(t, err)
+			require.Equal(t, tc.expected.Subject, actual.Subject, "ParseMessage() Subject mismatch")
+			require.Equal(t, tc.expected.Body, actual.Body, "ParseMessage() Body mismatch")
+			require.Equal(t, tc.expected.Headers, actual.Headers, "ParseMessage() Headers mismatch")
+
+			actualString := actual.ToString()
+			roundTrip, err := sender.ParseMessage(ctx, strings.NewReader(actualString))
+			require.NoError(t, err)
+
+			require.Equal(t, tc.expected, roundTrip, "ToString() did not roundtrip")
+		})
+	}
+}
+
+func TestParseMessageNoBody(t *testing.T) {
+	reader := strings.NewReader(`Subject: Test Subject`)
+	ctx := testlogging.Context(t)
+	_, err := sender.ParseMessage(ctx, reader)
+	require.ErrorContains(t, err, "no body found in message")
+}
+
+func TestToString(t *testing.T) {
+	msg := &sender.Message{
+		Subject: "Test Subject",
+		Headers: map[string]string{
+			"Header1": "Value1",
+			"Header2": "Value2",
+		},
+		Body: "This is the body of the message.",
+	}
+
+	expected := "Subject: Test Subject\nHeader1: Value1\nHeader2: Value2\n\nThis is the body of the message."
+	actual := msg.ToString()
+
+	if actual != expected {
+		t.Errorf("ToString() = %v, want %v", actual, expected)
+	}
+}
+
+func TestValidateMessageFormatAndSetDefault(t *testing.T) {
+	var f string
+
+	require.NoError(t, sender.ValidateMessageFormatAndSetDefault(&f, "html"))
+	require.Equal(t, "html", f)
+
+	f = "txt"
+	require.NoError(t, sender.ValidateMessageFormatAndSetDefault(&f, "html"))
+	require.Equal(t, "txt", f)
+
+	f = "html"
+	require.NoError(t, sender.ValidateMessageFormatAndSetDefault(&f, "html"))
+	require.Equal(t, "html", f)
+
+	f = "bad"
+	require.ErrorContains(t, sender.ValidateMessageFormatAndSetDefault(&f, "html"), "invalid format: bad")
+}
diff --git a/notification/sender/pushover/pushover_sender_test.go b/notification/sender/pushover/pushover_sender_test.go
new file mode 100644
index 00000000..cd3d6b6e
--- /dev/null
+++ b/notification/sender/pushover/pushover_sender_test.go
@@ -0,0 +1,131 @@
+package pushover_test
+
+import (
+	"bytes"
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/kopia/kopia/internal/testlogging"
+	"github.com/kopia/kopia/notification/sender"
+	"github.com/kopia/kopia/notification/sender/pushover"
+)
+
+func TestPushover(t *testing.T) {
+	ctx := testlogging.Context(t)
+
+	mux := http.NewServeMux()
+
+	var requests []*http.Request
+	var requestBodies []bytes.Buffer
+
+	mux.HandleFunc("/some-path", func(w http.ResponseWriter, r *http.Request) {
+		var b bytes.Buffer
+		io.Copy(&b, r.Body)
+
+		requestBodies = append(requestBodies, b)
+		requests = append(requests, r)
+	})
+
+	server := httptest.NewServer(mux)
+	defer server.Close()
+
+	p, err := sender.GetSender(ctx, "my-profile", "pushover", &pushover.Options{
+		AppToken: "app-token1",
+		UserKey:  "user-key1",
+		Endpoint: server.URL + "/some-path",
+	})
+	require.NoError(t, err)
+
+	ph, err := sender.GetSender(ctx, "my-html-profile", "pushover", &pushover.Options{
+		AppToken: "app-token1",
+		UserKey:  "user-key1",
+		Format:   "html",
+		Endpoint: server.URL + "/some-path",
+	})
+	require.NoError(t, err)
+	require.Equal(t, "Pushover user \"user-key1\" app \"app-token1\" format \"txt\"", p.Summary())
+
+	require.NoError(t, p.Send(ctx, &sender.Message{Subject: "Test", Body: "This is a test.\n\n* one\n* two\n* three\n\n# Header\n## Subheader\n\n- a\n- b\n- c"}))
+	require.NoError(t, ph.Send(ctx, &sender.Message{Subject: "Test", Body: "<p>This is a HTML test</p>"}))
+
+	require.Len(t, requests, 2)
+	require.Equal(t, "application/json", requests[0].Header.Get("Content-Type"))
+
+	var body map[string]interface{}
+
+	// Plain-text request
+	require.NoError(t, json.NewDecoder(&requestBodies[0]).Decode(&body))
+
+	require.Equal(t, "app-token1", body["token"])
+	require.Equal(t, "user-key1", body["user"])
+	require.Nil(t, body["html"])
+	require.Equal(t, "Test\n\nThis is a test.\n\n* one\n* two\n* three\n\n# Header\n## Subheader\n\n- a\n- b\n- c", body["message"])
+
+	require.NoError(t, json.NewDecoder(&requestBodies[1]).Decode(&body))
+
+	// HTML request
+	require.Equal(t, "app-token1", body["token"])
+	require.Equal(t, "user-key1", body["user"])
+	require.Equal(t, "1", body["html"])
+	require.Equal(t, "Test\n\n<p>This is a HTML test</p>", body["message"])
+
+	p2, err := sender.GetSender(ctx, "my-profile", "pushover", &pushover.Options{
+		AppToken: "app-token1",
+		UserKey:  "user-key1",
+		Endpoint: server.URL + "/not-found-path",
+	})
+	require.NoError(t, err)
+	require.ErrorContains(t, p2.Send(ctx, &sender.Message{Subject: "Test", Body: "test"}), "error sending pushover notification")
+
+	p3, err := sender.GetSender(ctx, "my-profile", "pushover", &pushover.Options{
+		AppToken: "app-token1",
+		UserKey:  "user-key1",
+		Endpoint: "http://localhost:59123/not-found-path",
+	})
+	require.NoError(t, err)
+	require.ErrorContains(t, p3.Send(ctx, &sender.Message{Subject: "Test", Body: "test"}), "error sending pushover notification")
+}
+
+func TestPushover_Invalid(t *testing.T) {
+	ctx := testlogging.Context(t)
+
+	_, err := sender.GetSender(ctx, "my-profile", "pushover", &pushover.Options{})
+	require.ErrorContains(t, err, "App Token must be provided")
+
+	_, err = sender.GetSender(ctx, "my-profile", "pushover", &pushover.Options{
+		AppToken: "some-token",
+	})
+	require.ErrorContains(t, err, "User Key must be provided")
+}
+
+func TestMergeOptions(t *testing.T) {
+	var dst pushover.Options
+
+	pushover.MergeOptions(pushover.Options{
+		AppToken: "app1",
+		UserKey:  "user1",
+	}, &dst, false)
+
+	require.Equal(t, "app1", dst.AppToken)
+	require.Equal(t, "user1", dst.UserKey)
+
+	pushover.MergeOptions(pushover.Options{
+		UserKey: "user2",
+	}, &dst, true)
+
+	require.Equal(t, "app1", dst.AppToken)
+	require.Equal(t, "user2", dst.UserKey)
+
+	pushover.MergeOptions(pushover.Options{
+		AppToken: "app2",
+		UserKey:  "user2",
+	}, &dst, true)
+
+	require.Equal(t, "app2", dst.AppToken)
+	require.Equal(t, "user2", dst.UserKey)
+}
diff --git a/notification/sender/testsender/test_sender.go b/notification/sender/testsender/test_sender.go
new file mode 100644
index 00000000..3792746a
--- /dev/null
+++ b/notification/sender/testsender/test_sender.go
@@ -0,0 +1,78 @@
+// Package testsender provides notification sender testing support.
+package testsender
+
+import (
+	"context"
+	"sync"
+
+	"github.com/pkg/errors"
+
+	"github.com/kopia/kopia/notification/sender"
+)
+
+// ProviderType defines the type of the test notification provider.
+const ProviderType = "testsender"
+
+type capturedMessagesContextKeyType string
+
+// capturedMessagesContextKey is a context key for captured messages.
+const capturedMessagesContextKey capturedMessagesContextKeyType = "capturedMessages"
+
+type capturedMessages struct {
+	messages []*sender.Message
+}
+
+// CaptureMessages captures messages sent in the provider context and returns a new context.
+// Captured messages can be retrieved using MessagesInContext.
+func CaptureMessages(ctx context.Context) context.Context {
+	return context.WithValue(ctx, capturedMessagesContextKey, &capturedMessages{})
+}
+
+// MessagesInContext retrieves messages sent in the provider context.
+func MessagesInContext(ctx context.Context) []*sender.Message {
+	if v, ok := ctx.Value(capturedMessagesContextKey).(*capturedMessages); ok {
+		return v.messages
+	}
+
+	return nil
+}
+
+type testSenderProvider struct {
+	mu sync.Mutex
+
+	opt Options
+}
+
+func (p *testSenderProvider) Send(ctx context.Context, msg *sender.Message) error {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+
+	cm, ok := ctx.Value(capturedMessagesContextKey).(*capturedMessages)
+	if !ok {
+		return errors.Errorf("test sender not configured")
+	}
+
+	cm.messages = append(cm.messages, msg)
+
+	return nil
+}
+
+func (p *testSenderProvider) Summary() string {
+	return "Test sender"
+}
+
+func (p *testSenderProvider) Format() string {
+	return p.opt.Format
+}
+
+func init() {
+	sender.Register(ProviderType, func(ctx context.Context, options *Options) (sender.Provider, error) {
+		if err := options.applyDefaultsAndValidate(); err != nil {
+			return nil, errors.Wrap(err, "invalid notification configuration")
+		}
+
+		return &testSenderProvider{
+			opt: *options,
+		}, nil
+	})
+}
diff --git a/notification/sender/testsender/test_sender_options.go b/notification/sender/testsender/test_sender_options.go
new file mode 100644
index 00000000..72ac5820
--- /dev/null
+++ b/notification/sender/testsender/test_sender_options.go
@@ -0,0 +1,33 @@
+package testsender
+
+import (
+	"github.com/pkg/errors"
+
+	"github.com/kopia/kopia/notification/sender"
+)
+
+// Options defines email notification provider options.
+type Options struct {
+	Format string `json:"format"` // format of the message, must be "html" or "md"
+}
+
+// MergeOptions updates the destination options with the source options.
+func MergeOptions(src Options, dst *Options, isUpdate bool) {
+	copyOrMerge(&dst.Format, src.Format, isUpdate)
+}
+
+func (o *Options) applyDefaultsAndValidate() error {
+	if err := sender.ValidateMessageFormatAndSetDefault(&o.Format, "html"); err != nil {
+		return errors.Wrap(err, "invalid format")
+	}
+
+	return nil
+}
+
+func copyOrMerge[T comparable](dst *T, src T, isUpdate bool) {
+	var defaultT T
+
+	if !isUpdate || src != defaultT {
+		*dst = src
+	}
+}
diff --git a/notification/sender/testsender/test_sender_test.go b/notification/sender/testsender/test_sender_test.go
new file mode 100644
index 00000000..9d0fb5c9
--- /dev/null
+++ b/notification/sender/testsender/test_sender_test.go
@@ -0,0 +1,57 @@
+package testsender_test
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/kopia/kopia/internal/testlogging"
+	"github.com/kopia/kopia/notification/sender"
+	"github.com/kopia/kopia/notification/sender/testsender"
+)
+
+func TestProvider(t *testing.T) {
+	ctx := testlogging.Context(t)
+
+	ctx = testsender.CaptureMessages(ctx)
+
+	p, err := sender.GetSender(ctx, "my-profile", "testsender", &testsender.Options{
+		Format: sender.FormatPlainText,
+	})
+	require.NoError(t, err)
+
+	require.Equal(t, "Test sender", p.Summary())
+	m1 := &sender.Message{
+		Subject: "test subject 1",
+	}
+	m2 := &sender.Message{
+		Subject: "test subject 2",
+	}
+	m3 := &sender.Message{
+		Subject: "test subject 3",
+	}
+	p.Send(ctx, m1)
+	p.Send(ctx, m2)
+	p.Send(ctx, m3)
+	mic := testsender.MessagesInContext(ctx)
+	require.ElementsMatch(t, mic, []*sender.Message{m1, m2, m3})
+}
+
+func TestProvider_NotConfigured(t *testing.T) {
+	ctx := testlogging.Context(t)
+
+	// do not call 'ctx = testsender.CaptureMessages(ctx)'
+	p, err := sender.GetSender(ctx, "my-profile", "testsender", &testsender.Options{
+		Format: "txt",
+	})
+	require.NoError(t, err)
+
+	require.Equal(t, "Test sender", p.Summary())
+	m1 := &sender.Message{
+		Subject: "test subject 1",
+	}
+	p.Send(ctx, m1)
+
+	// nothing captured
+	require.Empty(t, testsender.MessagesInContext(ctx))
+}
diff --git a/notification/sender/webhook/webhook_sender_test.go b/notification/sender/webhook/webhook_sender_test.go
new file mode 100644
index 00000000..ca8cd1b2
--- /dev/null
+++ b/notification/sender/webhook/webhook_sender_test.go
@@ -0,0 +1,170 @@
+package webhook_test
+
+import (
+	"bytes"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/kopia/kopia/internal/testlogging"
+	"github.com/kopia/kopia/notification/sender"
+	"github.com/kopia/kopia/notification/sender/webhook"
+)
+
+func TestWebhook(t *testing.T) {
+	ctx := testlogging.Context(t)
+
+	mux := http.NewServeMux()
+
+	var requests []*http.Request
+	var requestBodies []bytes.Buffer
+
+	mux.HandleFunc("/some-path", func(w http.ResponseWriter, r *http.Request) {
+		var b bytes.Buffer
+		io.Copy(&b, r.Body)
+
+		requestBodies = append(requestBodies, b)
+		requests = append(requests, r)
+	})
+
+	server := httptest.NewServer(mux)
+	defer server.Close()
+
+	p, err := sender.GetSender(ctx, "my-profile", "webhook", &webhook.Options{
+		Endpoint: server.URL + "/some-path",
+		Method:   "POST",
+	})
+	require.NoError(t, err)
+
+	require.NoError(t, p.Send(ctx, &sender.Message{
+		Subject: "Test",
+		Body:    "This is a test.\n\n* one\n* two\n* three\n\n# Header\n## Subheader\n\n- a\n- b\n- c",
+		Headers: map[string]string{
+			"X-Some-Header": "x",
+			"Content-Type":  "some/content-type",
+		},
+	}))
+
+	p2, err := sender.GetSender(ctx, "my-profile", "webhook", &webhook.Options{
+		Endpoint: server.URL + "/some-path",
+		Method:   "PUT",
+		Format:   "html",
+	})
+	require.NoError(t, err)
+
+	require.NoError(t, p2.Send(ctx, &sender.Message{
+		Subject: "Test 2",
+		Body:    "This is a test.\n\n* one\n* two\n* three",
+		Headers: map[string]string{
+			"Content-Type": "text/html",
+		},
+	}))
+
+	require.Len(t, requests, 2)
+
+	// first request - POST in md format
+	require.Equal(t, "some/content-type", requests[0].Header.Get("Content-Type"))
+	require.Equal(t, "x", requests[0].Header.Get("X-Some-Header"))
+	require.Equal(t, "Test", requests[0].Header.Get("Subject"))
+	require.Equal(t, "POST", requests[0].Method)
+	require.Equal(t,
+		"This is a test.\n\n* one\n* two\n* three\n\n# Header\n## Subheader\n\n- a\n- b\n- c",
+		requestBodies[0].String())
+
+	// second request - PUT in HTML format
+	require.Equal(t, "text/html", requests[1].Header.Get("Content-Type"))
+	require.Equal(t, "Test 2", requests[1].Header.Get("Subject"))
+	require.Equal(t, "PUT", requests[1].Method)
+	require.Equal(t, "This is a test.\n\n* one\n* two\n* three", requestBodies[1].String())
+
+	p3, err := sender.GetSender(ctx, "my-profile", "webhook", &webhook.Options{
+		Endpoint: server.URL + "/nonexixtent-path",
+	})
+	require.NoError(t, err)
+
+	require.Contains(t, p3.Summary(), "Webhook POST http://")
+
+	require.ErrorContains(t, p3.Send(ctx, &sender.Message{
+		Subject: "Test",
+		Body:    `This is a test.`,
+	}), "404")
+}
+
+func TestWebhook_Failure(t *testing.T) {
+	ctx := testlogging.Context(t)
+	p, err := sender.GetSender(ctx, "my-profile", "webhook", &webhook.Options{
+		Endpoint: "http://localhost:41123/no-such-path",
+	})
+	require.NoError(t, err)
+
+	require.ErrorContains(t, p.Send(ctx, &sender.Message{
+		Subject: "Test",
+		Body:    "test",
+	}), "error sending webhook notification")
+}
+
+func TestWebhook_InvalidURL(t *testing.T) {
+	ctx := testlogging.Context(t)
+	_, err := sender.GetSender(ctx, "my-profile", "webhook", &webhook.Options{
+		Endpoint: "!",
+	})
+	require.ErrorContains(t, err, "invalid endpoint")
+}
+
+func TestWebhook_InvalidURLScheme(t *testing.T) {
+	ctx := testlogging.Context(t)
+	_, err := sender.GetSender(ctx, "my-profile", "webhook", &webhook.Options{
+		Endpoint: "hfasd-ttp:",
+	})
+	require.ErrorContains(t, err, "invalid endpoint scheme, must be http:// or https://")
+}
+
+func TestWebhook_InvalidMethod(t *testing.T) {
+	ctx := testlogging.Context(t)
+	p, err := sender.GetSender(ctx, "my-profile", "webhook", &webhook.Options{
+		Endpoint: "http://localhost:41123/no-such-path",
+		Method:   "?",
+	})
+
+	require.NoError(t, err)
+
+	require.ErrorContains(t, p.Send(ctx, &sender.Message{
+		Subject: "Test",
+		Body:    "test",
+	}), "net/http: invalid method \"?\"")
+}
+
+func TestMergeOptions(t *testing.T) {
+	var dst webhook.Options
+
+	webhook.MergeOptions(webhook.Options{
+		Endpoint: "http://localhost:1234",
+		Method:   "POST",
+		Format:   "md",
+	}, &dst, false)
+
+	require.Equal(t, "http://localhost:1234", dst.Endpoint)
+	require.Equal(t, "POST", dst.Method)
+	require.Equal(t, "md", dst.Format)
+
+	webhook.MergeOptions(webhook.Options{
+		Method: "PUT",
+	}, &dst, true)
+
+	require.Equal(t, "http://localhost:1234", dst.Endpoint)
+	require.Equal(t, "PUT", dst.Method)
+	require.Equal(t, "md", dst.Format)
+
+	webhook.MergeOptions(webhook.Options{
+		Endpoint: "http://localhost:5678",
+		Method:   "PUT",
+		Format:   "html",
+	}, &dst, true)
+
+	require.Equal(t, "http://localhost:5678", dst.Endpoint)
+	require.Equal(t, "PUT", dst.Method)
+	require.Equal(t, "html", dst.Format)
+}
diff --git a/tests/testenv/cli_exe_runner.go b/tests/testenv/cli_exe_runner.go
index 39ee62e2..f1895455 100644
--- a/tests/testenv/cli_exe_runner.go
+++ b/tests/testenv/cli_exe_runner.go
@@ -1,6 +1,7 @@
 package testenv
 
 import (
+	"context"
 	"io"
 	"os"
 	"os/exec"
@@ -20,7 +21,7 @@ type CLIExeRunner struct {
 }
 
 // Start implements CLIRunner.
-func (e *CLIExeRunner) Start(t *testing.T, args []string, env map[string]string) (stdout, stderr io.Reader, wait func() error, interrupt func(os.Signal)) {
+func (e *CLIExeRunner) Start(t *testing.T, ctx context.Context, args []string, env map[string]string) (stdout, stderr io.Reader, wait func() error, interrupt func(os.Signal)) {
 	t.Helper()
 
 	c := exec.Command(e.Exe, append([]string{
diff --git a/tests/testenv/cli_inproc_runner.go b/tests/testenv/cli_inproc_runner.go
index 617a8914..a2696aeb 100644
--- a/tests/testenv/cli_inproc_runner.go
+++ b/tests/testenv/cli_inproc_runner.go
@@ -1,6 +1,7 @@
 package testenv
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"os"
@@ -11,7 +12,6 @@
 	"github.com/alecthomas/kingpin/v2"
 
 	"github.com/kopia/kopia/cli"
-	"github.com/kopia/kopia/internal/testlogging"
 )
 
 var envPrefixCounter = new(int32)
@@ -27,11 +27,9 @@ type CLIInProcRunner struct {
 }
 
 // Start implements CLIRunner.
-func (e *CLIInProcRunner) Start(t *testing.T, args []string, env map[string]string) (stdout, stderr io.Reader, wait func() error, interrupt func(os.Signal)) {
+func (e *CLIInProcRunner) Start(t *testing.T, ctx context.Context, args []string, env map[string]string) (stdout, stderr io.Reader, wait func() error, interrupt func(os.Signal)) {
 	t.Helper()
 
-	ctx := testlogging.Context(t)
-
 	a := cli.NewApp()
 	a.AdvancedCommands = "enabled"
 
diff --git a/tests/testenv/cli_test_env.go b/tests/testenv/cli_test_env.go
index fdd64054..6a914d3c 100644
--- a/tests/testenv/cli_test_env.go
+++ b/tests/testenv/cli_test_env.go
@@ -3,6 +3,7 @@
 
 import (
 	"bufio"
+	"context"
 	"io"
 	"io/fs"
 	"math/rand"
@@ -18,8 +19,11 @@
 	"github.com/stretchr/testify/require"
 
 	"github.com/kopia/kopia/internal/clock"
+	"github.com/kopia/kopia/internal/testlogging"
 	"github.com/kopia/kopia/internal/testutil"
 	"github.com/kopia/kopia/internal/timetrack"
+	"github.com/kopia/kopia/notification/sender"
+	"github.com/kopia/kopia/notification/sender/testsender"
 )
 
 const (
@@ -30,11 +34,15 @@
 // CLIRunner encapsulates running kopia subcommands for testing purposes.
 // It supports implementations that use subprocesses or in-process invocations.
 type CLIRunner interface {
-	Start(t *testing.T, args []string, env map[string]string) (stdout, stderr io.Reader, wait func() error, interrupt func(os.Signal))
+	Start(t *testing.T, ctx context.Context, args []string, env map[string]string) (stdout, stderr io.Reader, wait func() error, interrupt func(os.Signal))
 }
 
 // CLITest encapsulates state for a CLI-based test.
 type CLITest struct {
+	// context in which all subcommands are running
+	//nolint:containedctx
+	RunContext context.Context
+
 	startTime time.Time
 
 	RepoDir   string
@@ -91,6 +99,7 @@ func NewCLITest(t *testing.T, repoCreateFlags []string, runner CLIRunner) *CLITe
 	}
 
 	return &CLITest{
+		RunContext:                   testsender.CaptureMessages(testlogging.Context(t)),
 		startTime:                    clock.Now(),
 		RepoDir:                      testutil.TempDirectory(t),
 		ConfigDir:                    configDir,
@@ -154,6 +163,10 @@ func (e *CLITest) SetLogOutput(enable bool, prefix string) {
 	e.logOutputPrefix = prefix
 }
 
+func (e *CLITest) NotificationsSent() []*sender.Message {
+	return testsender.MessagesInContext(e.RunContext)
+}
+
 func (e *CLITest) getLogOutputPrefix() (string, bool) {
 	e.logMu.RLock()
 	defer e.logMu.RUnlock()
@@ -178,7 +191,7 @@ func (e *CLITest) RunAndProcessStderr(t *testing.T, callback func(line string) b
 func (e *CLITest) RunAndProcessStderrInt(t *testing.T, outputCallback func(line string) bool, args ...string) (wait func() error, interrupt func(os.Signal)) {
 	t.Helper()
 
-	stdout, stderr, wait, interrupt := e.Runner.Start(t, e.cmdArgs(args), e.Environment)
+	stdout, stderr, wait, interrupt := e.Runner.Start(t, e.RunContext, e.cmdArgs(args), e.Environment)
 
 	go func() {
 		scanner := bufio.NewScanner(stdout)
@@ -276,7 +289,7 @@ func (e *CLITest) Run(t *testing.T, expectedError bool, args ...string) (stdout,
 
 	timer := timetrack.StartTimer()
 
-	stdoutReader, stderrReader, wait, _ := e.Runner.Start(t, args, e.Environment)
+	stdoutReader, stderrReader, wait, _ := e.Runner.Start(t, e.RunContext, args, e.Environment)
 
 	var wg sync.WaitGroup