Repository: crazy-max/diun

Issue:
Add Apprise integration 
Please can you add Apprise integration for notifications,  it will solve your other notification integration as well.

Discussion:
@iodize6399: +1 an apprise integration will open enormous possibilities for sending push notifications to many platforms not natively found in diun@MvrckTheBald: +1@anotherspot: +1
@enricomarogna: +1@mwsmith824: This would definitely be a great addition, and also save work on notification integrations. It already integrates with 99% of what anyone would want so you wouldn't have to deal with it.@appiekap653: +1@s17t: +1 @gorootde: Any updates on this one?@adzero: Apprise support seems to be available to this PR. 

I checked the [documentation for notifications ](https://crazymax.dev/diun/notif/amqp/) and there's no mention of Apprise in it. 
I tried to search the word Apprise in the search field and there's no match in the entire documentation. 

Is it possible to complete the documentation to be make this new feature usable ?

EDIT: I opened [this issue](https://github.com/crazy-max/diun/issues/1485) as this one is closed.

Solution patch:
diff --git a/docs/config/index.md b/docs/config/index.md
index 90f82338..47e8cbc3 100644
--- a/docs/config/index.md
+++ b/docs/config/index.md
@@ -245,6 +245,7 @@ Can be transposed to:
 * [defaults](defaults.md)
 * notif
     * [amqp](../notif/amqp.md)
+    * [apprise](../notif/apprise.md)
     * [discord](../notif/discord.md)
     * [gotify](../notif/gotify.md)
     * [mail](../notif/mail.md)
diff --git a/docs/config/notif.md b/docs/config/notif.md
index 7705b155..ddfeac5a 100644
--- a/docs/config/notif.md
+++ b/docs/config/notif.md
@@ -1,6 +1,7 @@
 # Notifications configuration
 
 * [`amqp`](../notif/amqp.md)
+* [`apprise`](../notif/apprise.md)
 * [`discord`](../notif/discord.md)
 * [`gotify`](../notif/gotify.md)
 * [`mail`](../notif/mail.md)
diff --git a/docs/notif/apprise.md b/docs/notif/apprise.md
new file mode 100644
index 00000000..bbe1eb88
--- /dev/null
+++ b/docs/notif/apprise.md
@@ -0,0 +1,54 @@
+# Apprise notifications
+
+Notifications can be sent using an apprise api instance.
+
+## Configuration
+
+!!! example "File"
+    ```yaml
+        notif:
+          apprise:
+            endpoint: http://apprise:8000
+            token: abc
+            tags:
+              - diun
+            timeout: 10s
+            templateTitle: "{{ .Entry.Image }} released"
+            templateBody: |
+              Docker tag {{ .Entry.Image }} which you subscribed to through {{ .Entry.Provider }} provider has been released.
+    ```
+
+| Name            | Default                             | Description                                                                |
+|-----------------|-------------------------------------|----------------------------------------------------------------------------|
+| `endpoint`[^1]  |                                     | Hostname and port of your apprise api instance                             |
+| `token`[^2]     |                                     | token representing your config file (Config Key)                           |
+| `tokenFile`     |                                     | Use content of secret file as application token if `token` not defined     |
+| `tags`          |                                     | List of Tags in your config file you want to notify                        |
+| `urls`[^2]      |                                     | List of [URLs](https://github.com/caronc/apprise/wiki/URLBasics) to notify |
+| `timeout`       | `10s`                               | Timeout specifies a time limit for the request to be made                  |
+| `templateTitle` | See [below](#default-templatetitle) | [Notification template](../faq.md#notification-template) for message title |
+| `templateBody`  | See [below](#default-templatebody)  | [Notification template](../faq.md#notification-template) for message body  |
+
+!!! abstract "Environment variables"
+    * `DIUN_NOTIF_APPRISE_ENDPOINT`
+    * `DIUN_NOTIF_APPRISE_TOKEN`
+    * `DIUN_NOTIF_APPRISE_TAGS`
+    * `DIUN_NOTIF_APPRISE_URLS`
+    * `DIUN_NOTIF_APPRISE_TIMEOUT`
+    * `DIUN_NOTIF_APPRISE_TEMPLATETITLE`
+    * `DIUN_NOTIF_APPRISE_TEMPLATEBODY`
+
+### Default `templateTitle`
+
+```
+[[ config.extra.template.notif.defaultTitle ]]
+```
+
+### Default `templateBody`
+
+```
+[[ config.extra.template.notif.defaultBody ]]
+```
+
+[^1]: Value required
+[^2]: One of these 2 values is required
diff --git a/internal/config/fixtures/config.test.yml b/internal/config/fixtures/config.test.yml
index 6d8b94ce..cf9d6592 100644
--- a/internal/config/fixtures/config.test.yml
+++ b/internal/config/fixtures/config.test.yml
@@ -24,6 +24,11 @@ notif:
     username: guest
     password: guest
     queue: queue
+  apprise:
+    endpoint: http://apprise:8000
+    token: abc
+    tags:
+      - diun
   discord:
     webhookURL: https://discordapp.com/api/webhooks/1234567890/Abcd-eFgh-iJklmNo_pqr
     mentions:
diff --git a/internal/model/notif.go b/internal/model/notif.go
index 37a897d7..7261a624 100644
--- a/internal/model/notif.go
+++ b/internal/model/notif.go
@@ -33,6 +33,7 @@ type NotifEntry struct {
 // Notif holds data necessary for notification configuration
 type Notif struct {
 	Amqp       *NotifAmqp       `yaml:"amqp,omitempty" json:"amqp,omitempty"`
+	Apprise    *NotifApprise    `yaml:"apprise,omitempty" json:"apprise,omitempty"`
 	Discord    *NotifDiscord    `yaml:"discord,omitempty" json:"discord,omitempty"`
 	Gotify     *NotifGotify     `yaml:"gotify,omitempty" json:"gotify,omitempty"`
 	Mail       *NotifMail       `yaml:"mail,omitempty" json:"mail,omitempty"`
diff --git a/internal/model/notif_apprise.go b/internal/model/notif_apprise.go
new file mode 100644
index 00000000..de3649f1
--- /dev/null
+++ b/internal/model/notif_apprise.go
@@ -0,0 +1,33 @@
+package model
+
+import (
+	"time"
+
+	"github.com/crazy-max/diun/v4/pkg/utl"
+)
+
+// NotifApprise holds apprise notification configuration details
+type NotifApprise struct {
+	Endpoint      string         `yaml:"endpoint,omitempty" json:"endpoint,omitempty" validate:"required"`
+	Token         string         `yaml:"token,omitempty" json:"token,omitempty" validate:"omitempty"`
+	TokenFile     string         `yaml:"tokenFile,omitempty" json:"tokenFile,omitempty" validate:"omitempty,file"`
+	Tags          []string       `yaml:"tags,omitempty" json:"tags,omitempty" validate:"omitempty"`
+	URLs          []string       `yaml:"urls,omitempty" json:"urls,omitempty" validate:"omitempty"`
+	Timeout       *time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty" validate:"required"`
+	TemplateTitle string         `yaml:"templateTitle,omitempty" json:"templateTitle,omitempty" validate:"required"`
+	TemplateBody  string         `yaml:"templateBody,omitempty" json:"templateBody,omitempty" validate:"required"`
+}
+
+// GetDefaults gets the default values
+func (s *NotifApprise) GetDefaults() *NotifApprise {
+	n := &NotifApprise{}
+	n.SetDefaults()
+	return n
+}
+
+// SetDefaults sets the default values
+func (s *NotifApprise) SetDefaults() {
+	s.Timeout = utl.NewDuration(10 * time.Second)
+	s.TemplateTitle = NotifDefaultTemplateTitle
+	s.TemplateBody = NotifDefaultTemplateBody
+}
diff --git a/internal/notif/apprise/client.go b/internal/notif/apprise/client.go
new file mode 100644
index 00000000..0e1952dd
--- /dev/null
+++ b/internal/notif/apprise/client.go
@@ -0,0 +1,121 @@
+package apprise
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/url"
+	"path"
+
+	"github.com/crazy-max/diun/v4/internal/model"
+	"github.com/crazy-max/diun/v4/internal/msg"
+	"github.com/crazy-max/diun/v4/internal/notif/notifier"
+	"github.com/crazy-max/diun/v4/pkg/utl"
+	"github.com/pkg/errors"
+)
+
+// Client represents an active apprise notification object
+type Client struct {
+	*notifier.Notifier
+	cfg  *model.NotifApprise
+	meta model.Meta
+}
+
+// New creates a new apprise notification instance
+func New(config *model.NotifApprise, meta model.Meta) notifier.Notifier {
+	return notifier.Notifier{
+		Handler: &Client{
+			cfg:  config,
+			meta: meta,
+		},
+	}
+}
+
+// Name returns notifier's name
+func (c *Client) Name() string {
+	return "apprise"
+}
+
+// Send creates and sends a apprise notification with an entry
+func (c *Client) Send(entry model.NotifEntry) error {
+	message, err := msg.New(msg.Options{
+		Meta:          c.meta,
+		Entry:         entry,
+		TemplateTitle: c.cfg.TemplateTitle,
+		TemplateBody:  c.cfg.TemplateBody,
+	})
+	if err != nil {
+		return err
+	}
+
+	title, body, err := message.RenderMarkdown()
+	if err != nil {
+		return err
+	}
+
+	dataBuf := new(bytes.Buffer)
+	if err := json.NewEncoder(dataBuf).Encode(struct {
+		Message string   `json:"body"`
+		Title   string   `json:"title"`
+		Tags    []string `json:"tags"`
+		URLs    []string `json:"urls"`
+	}{
+		Message: string(body),
+		Title:   string(title),
+		Tags:    c.cfg.Tags,
+		URLs:    c.cfg.URLs,
+	}); err != nil {
+		return err
+	}
+
+	u, err := url.Parse(c.cfg.Endpoint)
+	if err != nil {
+		return err
+	}
+
+	u.Path = path.Join(u.Path, "notify")
+
+	if c.cfg.Token != "" || c.cfg.TokenFile != "" {
+		token, err := utl.GetSecret(c.cfg.Token, c.cfg.TokenFile)
+		if err != nil {
+			return errors.Wrap(err, "cannot retrieve token secret for Apprise notifier")
+		}
+		u.Path = path.Join(u.Path, token)
+	}
+
+	q := u.Query()
+	u.RawQuery = q.Encode()
+
+	hc := http.Client{}
+	ctx, cancel := context.WithTimeout(context.Background(), *c.cfg.Timeout)
+	defer cancel()
+
+	req, err := http.NewRequestWithContext(ctx, "POST", u.String(), dataBuf)
+	if err != nil {
+		return err
+	}
+
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("User-Agent", c.meta.UserAgent)
+
+	resp, err := hc.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		var errBody struct {
+			Error            string `json:"error"`
+			ErrorCode        int    `json:"errorCode"`
+			ErrorDescription string `json:"errorDescription"`
+		}
+		if err := json.NewDecoder(resp.Body).Decode(&errBody); err != nil {
+			return errors.Wrapf(err, "cannot decode JSON error response for HTTP %d %s status", resp.StatusCode, http.StatusText(resp.StatusCode))
+		}
+		return errors.Errorf("%d %s: %s", errBody.ErrorCode, errBody.Error, errBody.ErrorDescription)
+	}
+
+	return nil
+}
diff --git a/internal/notif/client.go b/internal/notif/client.go
index f18622b9..fc71ed5b 100644
--- a/internal/notif/client.go
+++ b/internal/notif/client.go
@@ -5,6 +5,7 @@ import (
 
 	"github.com/crazy-max/diun/v4/internal/model"
 	"github.com/crazy-max/diun/v4/internal/notif/amqp"
+	"github.com/crazy-max/diun/v4/internal/notif/apprise"
 	"github.com/crazy-max/diun/v4/internal/notif/discord"
 	"github.com/crazy-max/diun/v4/internal/notif/gotify"
 	"github.com/crazy-max/diun/v4/internal/notif/mail"
@@ -47,6 +48,9 @@ func New(config *model.Notif, meta model.Meta) (*Client, error) {
 	if config.Amqp != nil {
 		c.notifiers = append(c.notifiers, amqp.New(config.Amqp, meta))
 	}
+	if config.Apprise != nil {
+		c.notifiers = append(c.notifiers, apprise.New(config.Apprise, meta))
+	}
 	if config.Discord != nil {
 		c.notifiers = append(c.notifiers, discord.New(config.Discord, meta))
 	}
diff --git a/mkdocs.yml b/mkdocs.yml
index 2d7cd81a..23016ad7 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -118,6 +118,7 @@ nav:
     - .providers: config/providers.md
   - Notifications:
     - Amqp: notif/amqp.md
+    - Apprise: notif/apprise.md
     - Discord: notif/discord.md
     - Gotify: notif/gotify.md
     - Mail: notif/mail.md



Test patch:
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 1feedcfe..472708a6 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -73,6 +73,14 @@ func TestLoadFile(t *testing.T) {
 						Password: "guest",
 						Queue:    "queue",
 					},
+					Apprise: &model.NotifApprise{
+						Endpoint:      "http://apprise:8000",
+						Token:         "abc",
+						Tags:          []string{"diun"},
+						Timeout:       utl.NewDuration(10 * time.Second),
+						TemplateTitle: model.NotifDefaultTemplateTitle,
+						TemplateBody:  model.NotifDefaultTemplateBody,
+					},
 					Discord: &model.NotifDiscord{
 						WebhookURL: "https://discordapp.com/api/webhooks/1234567890/Abcd-eFgh-iJklmNo_pqr",
 						Mentions: []string{