Просмотр исходного кода

feat(integration): add Gotify push notification support

AdamMagued 1 неделя назад
Родитель
Сommit
f66e87603b
33 измененных файлов с 870 добавлено и 4 удалено
  1. 1 1
      README.md
  2. 11 0
      internal/database/migrations.go
  3. 145 0
      internal/integration/gotify/gotify.go
  4. 539 0
      internal/integration/gotify/gotify_test.go
  5. 19 0
      internal/integration/integration.go
  6. 4 0
      internal/locale/translations/ar_SA.json
  7. 4 0
      internal/locale/translations/de_DE.json
  8. 4 0
      internal/locale/translations/el_EL.json
  9. 4 0
      internal/locale/translations/en_US.json
  10. 4 0
      internal/locale/translations/es_ES.json
  11. 4 0
      internal/locale/translations/fi_FI.json
  12. 4 0
      internal/locale/translations/fr_FR.json
  13. 4 0
      internal/locale/translations/gl_ES.json
  14. 4 0
      internal/locale/translations/hi_IN.json
  15. 4 0
      internal/locale/translations/id_ID.json
  16. 4 0
      internal/locale/translations/it_IT.json
  17. 4 0
      internal/locale/translations/ja_JP.json
  18. 4 0
      internal/locale/translations/ko_KR.json
  19. 4 0
      internal/locale/translations/nan_Latn_pehoeji.json
  20. 4 0
      internal/locale/translations/nl_NL.json
  21. 4 0
      internal/locale/translations/pl_PL.json
  22. 4 0
      internal/locale/translations/pt_BR.json
  23. 4 0
      internal/locale/translations/ro_RO.json
  24. 4 0
      internal/locale/translations/ru_RU.json
  25. 4 0
      internal/locale/translations/tr_TR.json
  26. 4 0
      internal/locale/translations/uk_UA.json
  27. 4 0
      internal/locale/translations/zh_CN.json
  28. 4 0
      internal/locale/translations/zh_TW.json
  29. 4 0
      internal/model/integration.go
  30. 19 3
      internal/storage/integration.go
  31. 22 0
      internal/template/templates/views/integrations.html
  32. 14 0
      internal/ui/form/integration.go
  33. 4 0
      internal/ui/integration_show.go

+ 1 - 1
README.md

@@ -72,7 +72,7 @@ Features
 
 ### Integrations
 
-- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkTaco](https://linktaco.com), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
+- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Gotify](https://gotify.net/), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkTaco](https://linktaco.com), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
 - Bookmarklet for subscribing to websites directly from any web browser.
 - Webhooks for real-time notifications or custom integrations.
 - Compatibility with existing mobile applications using the Fever or Google Reader API.

+ 11 - 0
internal/database/migrations.go

@@ -1579,4 +1579,15 @@ var migrations = [...]func(tx *sql.Tx) error{
 		`)
 		return err
 	},
+	func(tx *sql.Tx) (err error) {
+		sql := `
+			ALTER TABLE integrations
+				ADD COLUMN gotify_enabled bool default 'f',
+				ADD COLUMN gotify_url text default '',
+				ADD COLUMN gotify_token text default '',
+				ADD COLUMN gotify_priority int default 0;
+		`
+		_, err = tx.Exec(sql)
+		return err
+	},
 }

+ 145 - 0
internal/integration/gotify/gotify.go

@@ -0,0 +1,145 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package gotify // import "miniflux.app/v2/internal/integration/gotify"
+
+import (
+	"errors"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"strings"
+
+	"miniflux.app/v2/internal/http/client"
+	"miniflux.app/v2/internal/model"
+	"miniflux.app/v2/internal/urllib"
+)
+
+type Client struct {
+	baseURL  string
+	token    string
+	priority int
+}
+
+func NewClient(baseURL, token string, priority int) *Client {
+	if priority < 0 {
+		priority = 0
+	}
+
+	return &Client{
+		baseURL:  baseURL,
+		token:    token,
+		priority: priority,
+	}
+}
+
+// Payload represents a Gotify push notification message payload.
+type Payload struct {
+	Title    string         `json:"title,omitempty"`
+	Message  string         `json:"message"`
+	Priority int            `json:"priority,omitempty"`
+	Extras   map[string]any `json:"extras,omitempty"`
+}
+
+// Validate validates the Gotify message payload according to the Gotify schema.
+func (p *Payload) Validate() error {
+	if p.Message == "" {
+		return errors.New("gotify: missing message")
+	}
+
+	if p.Priority < 0 {
+		return errors.New("gotify: priority must be non-negative")
+	}
+
+	return nil
+}
+
+func (c *Client) SendMessage(title, message string, priority int, extras map[string]any) error {
+	if c.baseURL == "" || c.token == "" {
+		return errors.New("gotify: missing base URL or token")
+	}
+
+	msg := &Payload{
+		Title:    title,
+		Message:  message,
+		Priority: priority,
+		Extras:   extras,
+	}
+
+	if err := msg.Validate(); err != nil {
+		return err
+	}
+
+	apiEndpoint := strings.TrimRight(c.baseURL, "/")
+	if !strings.HasSuffix(apiEndpoint, "/message") {
+		var err error
+		apiEndpoint, err = urllib.JoinBaseURLAndPath(c.baseURL, "/message")
+		if err != nil {
+			return fmt.Errorf("gotify: invalid API endpoint: %w", err)
+		}
+	}
+
+	response, err := client.NewRequestBuilder(apiEndpoint).
+		WithMethod(http.MethodPost).
+		WithHeader("X-Gotify-Key", c.token).
+		WithJSON(msg).
+		Do()
+	if err != nil {
+		return fmt.Errorf("gotify: %w", err)
+	}
+	defer response.Body.Close()
+
+	if response.StatusCode >= 400 {
+		return fmt.Errorf("gotify: unable to send message: url=%s status=%d", apiEndpoint, response.StatusCode)
+	}
+
+	return nil
+}
+
+func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
+	if c.baseURL == "" || c.token == "" {
+		return errors.New("gotify: missing base URL or token")
+	}
+
+	for _, entry := range entries {
+		if entry == nil {
+			continue
+		}
+
+		message := entry.Title
+		if message == "" {
+			message = entry.URL
+		}
+
+		if message == "" {
+			continue
+		}
+
+		slog.Debug("Sending Gotify notification",
+			slog.String("url", c.baseURL),
+			slog.String("title", feed.Title),
+			slog.String("entry_title", entry.Title),
+			slog.String("entry_url", entry.URL),
+		)
+
+		var title string
+		if feed != nil {
+			title = feed.Title
+		}
+
+		extras := make(map[string]any)
+		if entry.URL != "" {
+			extras["client::notification"] = map[string]any{
+				"click": map[string]any{
+					"url": entry.URL,
+				},
+			}
+		}
+
+		if err := c.SendMessage(title, message, c.priority, extras); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}

+ 539 - 0
internal/integration/gotify/gotify_test.go

@@ -0,0 +1,539 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package gotify
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/model"
+)
+
+func TestSendMessageSuccess(t *testing.T) {
+	configureIntegrationAllowPrivateNetworksOption(t)
+
+	var receivedToken string
+	var receivedContentType string
+	var receivedBody map[string]any
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodPost {
+			t.Errorf("expected POST method, got %s", r.Method)
+		}
+		if r.URL.Path != "/message" {
+			t.Errorf("expected path /message, got %s", r.URL.Path)
+		}
+
+		receivedToken = r.Header.Get("X-Gotify-Key")
+		receivedContentType = r.Header.Get("Content-Type")
+
+		body, err := io.ReadAll(r.Body)
+		if err != nil {
+			t.Fatalf("failed to read body: %v", err)
+		}
+
+		if err := json.Unmarshal(body, &receivedBody); err != nil {
+			t.Fatalf("failed to unmarshal request body: %v", err)
+		}
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(`{"id":1,"appid":1,"message":"test message","title":"test title","priority":5}`))
+	}))
+	defer server.Close()
+
+	client := NewClient(server.URL, "test-token-123", 5)
+
+	extras := map[string]any{
+		"client::notification": map[string]any{
+			"click": map[string]any{
+				"url": "https://example.com/article",
+			},
+		},
+	}
+
+	err := client.SendMessage("test title", "test message", 5, extras)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	if receivedToken != "test-token-123" {
+		t.Errorf("expected token %q, got %q", "test-token-123", receivedToken)
+	}
+	if receivedContentType != "application/json" {
+		t.Errorf("expected content type %q, got %q", "application/json", receivedContentType)
+	}
+	if receivedBody["title"] != "test title" {
+		t.Errorf("expected title %q, got %v", "test title", receivedBody["title"])
+	}
+	if receivedBody["message"] != "test message" {
+		t.Errorf("expected message %q, got %v", "test message", receivedBody["message"])
+	}
+	if int(receivedBody["priority"].(float64)) != 5 {
+		t.Errorf("expected priority 5, got %v", receivedBody["priority"])
+	}
+
+	notificationExtra, ok := receivedBody["extras"].(map[string]any)["client::notification"].(map[string]any)
+	if !ok {
+		t.Fatalf("expected client::notification extra, got %v", receivedBody["extras"])
+	}
+	clickExtra, ok := notificationExtra["click"].(map[string]any)
+	if !ok {
+		t.Fatalf("expected click extra, got %v", notificationExtra)
+	}
+	if clickExtra["url"] != "https://example.com/article" {
+		t.Errorf("expected click url %q, got %v", "https://example.com/article", clickExtra["url"])
+	}
+}
+
+func TestSendMessageWithoutPriorityOrExtras(t *testing.T) {
+	configureIntegrationAllowPrivateNetworksOption(t)
+
+	var receivedBody map[string]any
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		body, err := io.ReadAll(r.Body)
+		if err != nil {
+			t.Fatalf("failed to read body: %v", err)
+		}
+
+		if err := json.Unmarshal(body, &receivedBody); err != nil {
+			t.Fatalf("failed to unmarshal request body: %v", err)
+		}
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(`{"id":2,"message":"plain message"}`))
+	}))
+	defer server.Close()
+
+	client := NewClient(server.URL, "test-token", 0)
+	err := client.SendMessage("", "plain message", 0, nil)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	if receivedBody["message"] != "plain message" {
+		t.Errorf("expected message %q, got %v", "plain message", receivedBody["message"])
+	}
+	if _, exists := receivedBody["title"]; exists {
+		t.Errorf("expected title to be omitted, got %v", receivedBody["title"])
+	}
+	if _, exists := receivedBody["priority"]; exists {
+		t.Errorf("expected priority to be omitted, got %v", receivedBody["priority"])
+	}
+	if _, exists := receivedBody["extras"]; exists {
+		t.Errorf("expected extras to be omitted, got %v", receivedBody["extras"])
+	}
+}
+
+func TestSendMessageEndpointFormats(t *testing.T) {
+	configureIntegrationAllowPrivateNetworksOption(t)
+
+	requestCount := 0
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		requestCount++
+		if r.URL.Path != "/message" {
+			t.Errorf("expected path /message, got %s", r.URL.Path)
+		}
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(`{"id":1}`))
+	}))
+	defer server.Close()
+
+	testURLs := []string{
+		server.URL,
+		server.URL + "/",
+		server.URL + "/message",
+		server.URL + "/message/",
+	}
+
+	for _, u := range testURLs {
+		client := NewClient(u, "test-token", 0)
+		if err := client.SendMessage("title", "msg", 0, nil); err != nil {
+			t.Errorf("failed for URL %q: %v", u, err)
+		}
+	}
+
+	if requestCount != len(testURLs) {
+		t.Errorf("expected %d requests, got %d", len(testURLs), requestCount)
+	}
+}
+
+func TestSendMessageValidationErrors(t *testing.T) {
+	tests := []struct {
+		name        string
+		baseURL     string
+		token       string
+		message     string
+		priority    int
+		errContains string
+	}{
+		{
+			name:        "missing base URL",
+			baseURL:     "",
+			token:       "token",
+			message:     "hello",
+			priority:    0,
+			errContains: "gotify: missing base URL or token",
+		},
+		{
+			name:        "missing token",
+			baseURL:     "https://gotify.example.com",
+			token:       "",
+			message:     "hello",
+			priority:    0,
+			errContains: "gotify: missing base URL or token",
+		},
+		{
+			name:        "missing message",
+			baseURL:     "https://gotify.example.com",
+			token:       "token",
+			message:     "",
+			priority:    0,
+			errContains: "gotify: missing message",
+		},
+		{
+			name:        "negative priority",
+			baseURL:     "https://gotify.example.com",
+			token:       "token",
+			message:     "hello",
+			priority:    -1,
+			errContains: "gotify: priority must be non-negative",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			client := NewClient(tt.baseURL, tt.token, 0)
+			err := client.SendMessage("title", tt.message, tt.priority, nil)
+			if err == nil {
+				t.Fatalf("expected error, got nil")
+			}
+			if !strings.Contains(err.Error(), tt.errContains) {
+				t.Fatalf("expected error containing %q, got %q", tt.errContains, err.Error())
+			}
+		})
+	}
+}
+
+func TestSendMessageServerErrors(t *testing.T) {
+	configureIntegrationAllowPrivateNetworksOption(t)
+
+	tests := []struct {
+		name        string
+		statusCode  int
+		response    string
+		errContains string
+	}{
+		{
+			name:        "unauthorized 401",
+			statusCode:  http.StatusUnauthorized,
+			response:    `{"error":"Unauthorized","errorCode":401}`,
+			errContains: "status=401",
+		},
+		{
+			name:        "bad request 400",
+			statusCode:  http.StatusBadRequest,
+			response:    `{"error":"Bad Request","errorCode":400}`,
+			errContains: "status=400",
+		},
+		{
+			name:        "internal server error 500",
+			statusCode:  http.StatusInternalServerError,
+			response:    `{"error":"Internal Error","errorCode":500}`,
+			errContains: "status=500",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(tt.statusCode)
+				w.Write([]byte(tt.response))
+			}))
+			defer server.Close()
+
+			client := NewClient(server.URL, "token", 0)
+			err := client.SendMessage("title", "msg", 0, nil)
+			if err == nil {
+				t.Fatalf("expected error, got nil")
+			}
+			if !strings.Contains(err.Error(), tt.errContains) {
+				t.Fatalf("expected error containing %q, got %q", tt.errContains, err.Error())
+			}
+		})
+	}
+}
+
+func TestSendMessages(t *testing.T) {
+	configureIntegrationAllowPrivateNetworksOption(t)
+
+	var recordedRequests []map[string]any
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		body, err := io.ReadAll(r.Body)
+		if err != nil {
+			t.Fatalf("failed to read body: %v", err)
+		}
+
+		var parsed map[string]any
+		if err := json.Unmarshal(body, &parsed); err != nil {
+			t.Fatalf("failed to unmarshal request body: %v", err)
+		}
+
+		recordedRequests = append(recordedRequests, parsed)
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(`{"id":1}`))
+	}))
+	defer server.Close()
+
+	client := NewClient(server.URL, "token", 6)
+	feed := &model.Feed{Title: "Feed Title"}
+	entries := model.Entries{
+		{
+			Title: "First Article",
+			URL:   "https://example.com/first",
+		},
+		{
+			Title: "",
+			URL:   "https://example.com/second",
+		},
+		nil,
+	}
+
+	err := client.SendMessages(feed, entries)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	if len(recordedRequests) != 2 {
+		t.Fatalf("expected 2 recorded requests, got %d", len(recordedRequests))
+	}
+
+	req1 := recordedRequests[0]
+	if req1["title"] != "Feed Title" {
+		t.Errorf("req1: expected title %q, got %v", "Feed Title", req1["title"])
+	}
+	if req1["message"] != "First Article" {
+		t.Errorf("req1: expected message %q, got %v", "First Article", req1["message"])
+	}
+	if int(req1["priority"].(float64)) != 6 {
+		t.Errorf("req1: expected priority 6, got %v", req1["priority"])
+	}
+	extra1 := req1["extras"].(map[string]any)["client::notification"].(map[string]any)["click"].(map[string]any)
+	if extra1["url"] != "https://example.com/first" {
+		t.Errorf("req1: expected click url %q, got %v", "https://example.com/first", extra1["url"])
+	}
+
+	req2 := recordedRequests[1]
+	if req2["title"] != "Feed Title" {
+		t.Errorf("req2: expected title %q, got %v", "Feed Title", req2["title"])
+	}
+	if req2["message"] != "https://example.com/second" {
+		t.Errorf("req2: expected fallback message %q, got %v", "https://example.com/second", req2["message"])
+	}
+	extra2 := req2["extras"].(map[string]any)["client::notification"].(map[string]any)["click"].(map[string]any)
+	if extra2["url"] != "https://example.com/second" {
+		t.Errorf("req2: expected click url %q, got %v", "https://example.com/second", extra2["url"])
+	}
+}
+
+func TestPayloadSchemaValidation(t *testing.T) {
+	t.Run("valid minimal payload", func(t *testing.T) {
+		payload := &Payload{
+			Message: "A minimal notification message",
+		}
+		if err := payload.Validate(); err != nil {
+			t.Fatalf("expected valid payload, got error: %v", err)
+		}
+
+		data, err := json.Marshal(payload)
+		if err != nil {
+			t.Fatalf("unexpected marshal error: %v", err)
+		}
+
+		var decoded map[string]any
+		if err := json.Unmarshal(data, &decoded); err != nil {
+			t.Fatalf("unexpected unmarshal error: %v", err)
+		}
+
+		if decoded["message"] != "A minimal notification message" {
+			t.Errorf("expected message %q, got %v", "A minimal notification message", decoded["message"])
+		}
+		if _, ok := decoded["title"]; ok {
+			t.Errorf("title should be omitted when empty")
+		}
+		if _, ok := decoded["priority"]; ok {
+			t.Errorf("priority should be omitted when zero")
+		}
+		if _, ok := decoded["extras"]; ok {
+			t.Errorf("extras should be omitted when nil")
+		}
+	})
+
+	t.Run("valid complete payload with notification click extras", func(t *testing.T) {
+		payload := &Payload{
+			Title:    "Feed Update",
+			Message:  "New post published",
+			Priority: 5,
+			Extras: map[string]any{
+				"client::notification": map[string]any{
+					"click": map[string]any{
+						"url": "https://example.com/post",
+					},
+				},
+			},
+		}
+		if err := payload.Validate(); err != nil {
+			t.Fatalf("expected valid payload, got error: %v", err)
+		}
+
+		data, err := json.Marshal(payload)
+		if err != nil {
+			t.Fatalf("unexpected marshal error: %v", err)
+		}
+
+		var decoded map[string]any
+		if err := json.Unmarshal(data, &decoded); err != nil {
+			t.Fatalf("unexpected unmarshal error: %v", err)
+		}
+
+		if decoded["title"] != "Feed Update" {
+			t.Errorf("expected title %q, got %v", "Feed Update", decoded["title"])
+		}
+		if decoded["message"] != "New post published" {
+			t.Errorf("expected message %q, got %v", "New post published", decoded["message"])
+		}
+		if int(decoded["priority"].(float64)) != 5 {
+			t.Errorf("expected priority 5, got %v", decoded["priority"])
+		}
+
+		extras, ok := decoded["extras"].(map[string]any)
+		if !ok {
+			t.Fatalf("expected extras map, got %v", decoded["extras"])
+		}
+		notif, ok := extras["client::notification"].(map[string]any)
+		if !ok {
+			t.Fatalf("expected client::notification map, got %v", extras)
+		}
+		click, ok := notif["click"].(map[string]any)
+		if !ok {
+			t.Fatalf("expected click map, got %v", notif)
+		}
+		if click["url"] != "https://example.com/post" {
+			t.Errorf("expected click url %q, got %v", "https://example.com/post", click["url"])
+		}
+	})
+
+	t.Run("invalid payload with empty message", func(t *testing.T) {
+		payload := &Payload{
+			Title:   "Some Title",
+			Message: "",
+		}
+		err := payload.Validate()
+		if err == nil {
+			t.Fatalf("expected error for empty message, got nil")
+		}
+		if !strings.Contains(err.Error(), "gotify: missing message") {
+			t.Errorf("expected missing message error, got %v", err)
+		}
+	})
+
+	t.Run("invalid payload with negative priority", func(t *testing.T) {
+		payload := &Payload{
+			Message:  "Valid message",
+			Priority: -2,
+		}
+		err := payload.Validate()
+		if err == nil {
+			t.Fatalf("expected error for negative priority, got nil")
+		}
+		if !strings.Contains(err.Error(), "gotify: priority must be non-negative") {
+			t.Errorf("expected non-negative priority error, got %v", err)
+		}
+	})
+}
+
+func TestSendMessagesMissingConfigOrEmptyEntries(t *testing.T) {
+	configureIntegrationAllowPrivateNetworksOption(t)
+
+	t.Run("missing base URL returns error", func(t *testing.T) {
+		client := NewClient("", "token", 0)
+		err := client.SendMessages(&model.Feed{}, model.Entries{{Title: "Test"}})
+		if err == nil {
+			t.Fatal("expected error for missing base URL, got nil")
+		}
+		if !strings.Contains(err.Error(), "gotify: missing base URL or token") {
+			t.Errorf("unexpected error: %v", err)
+		}
+	})
+
+	t.Run("missing token returns error", func(t *testing.T) {
+		client := NewClient("https://gotify.example.com", "", 0)
+		err := client.SendMessages(&model.Feed{}, model.Entries{{Title: "Test"}})
+		if err == nil {
+			t.Fatal("expected error for missing token, got nil")
+		}
+		if !strings.Contains(err.Error(), "gotify: missing base URL or token") {
+			t.Errorf("unexpected error: %v", err)
+		}
+	})
+
+	t.Run("skip entries with empty title and empty url", func(t *testing.T) {
+		requestCount := 0
+		server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			requestCount++
+			w.WriteHeader(http.StatusOK)
+			w.Write([]byte(`{"id":1}`))
+		}))
+		defer server.Close()
+
+		client := NewClient(server.URL, "token", 0)
+		feed := &model.Feed{Title: "Feed"}
+		entries := model.Entries{
+			nil,
+			{Title: "", URL: ""},
+			{Title: "Valid Title", URL: "https://example.com/item"},
+		}
+
+		if err := client.SendMessages(feed, entries); err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+
+		if requestCount != 1 {
+			t.Errorf("expected 1 request for valid entry, got %d", requestCount)
+		}
+	})
+}
+
+func TestNewClientPriorityClamping(t *testing.T) {
+	client := NewClient("https://gotify.example.com", "token", -5)
+	if client.priority != 0 {
+		t.Errorf("expected negative priority clamped to 0, got %d", client.priority)
+	}
+}
+
+func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
+	t.Helper()
+
+	t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
+
+	configParser := config.NewConfigParser()
+	parsedOptions, err := configParser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf("Unable to configure test options: %v", err)
+	}
+
+	previousOptions := config.Opts
+	config.Opts = parsedOptions
+	t.Cleanup(func() {
+		config.Opts = previousOptions
+	})
+}

+ 19 - 0
internal/integration/integration.go

@@ -12,6 +12,7 @@ import (
 	"miniflux.app/v2/internal/integration/cubox"
 	"miniflux.app/v2/internal/integration/discord"
 	"miniflux.app/v2/internal/integration/espial"
+	"miniflux.app/v2/internal/integration/gotify"
 	"miniflux.app/v2/internal/integration/instapaper"
 	"miniflux.app/v2/internal/integration/karakeep"
 	"miniflux.app/v2/internal/integration/linkace"
@@ -662,6 +663,24 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
 		}
 	}
 
+	if userIntegrations.GotifyEnabled {
+		slog.Debug("Sending new entries to Gotify",
+			slog.Int64("user_id", userIntegrations.UserID),
+			slog.Int("nb_entries", len(entries)),
+			slog.Int64("feed_id", feed.ID),
+		)
+
+		client := gotify.NewClient(
+			userIntegrations.GotifyURL,
+			userIntegrations.GotifyToken,
+			userIntegrations.GotifyPriority,
+		)
+
+		if err := client.SendMessages(feed, entries); err != nil {
+			slog.Warn("Unable to send new entries to Gotify", slog.Any("error", err))
+		}
+	}
+
 	// Integrations that only support sending individual entries
 	if userIntegrations.TelegramBotEnabled {
 		for _, entry := range entries {

+ 4 - 0
internal/locale/translations/ar_SA.json

@@ -258,6 +258,10 @@
     "form.integration.googlereader_endpoint": "نقطة نهاية Google Reader API:",
     "form.integration.googlereader_password": "كلمة مرور Google Reader",
     "form.integration.googlereader_username": "اسم مستخدم Google Reader",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "حفظ المقالات في Instapaper",
     "form.integration.instapaper_password": "كلمة مرور Instapaper",
     "form.integration.instapaper_username": "اسم مستخدم Instapaper",

+ 4 - 0
internal/locale/translations/de_DE.json

@@ -246,6 +246,10 @@
     "form.integration.googlereader_endpoint": "Google-Reader-API-Endpunkt:",
     "form.integration.googlereader_password": "Google-Reader-Passwort",
     "form.integration.googlereader_username": "Google-Reader-Benutzername",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "Artikel in Instapaper speichern",
     "form.integration.instapaper_password": "Instapaper-Passwort",
     "form.integration.instapaper_username": "Instapaper-Benutzername",

+ 4 - 0
internal/locale/translations/el_EL.json

@@ -246,6 +246,10 @@
     "form.integration.googlereader_endpoint": "Τελικό σημείο Google Reader API:",
     "form.integration.googlereader_password": "Κωδικός Πρόσβασης Google Reader",
     "form.integration.googlereader_username": "Όνομα Χρήστη Google Reader",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "Αποθήκευση άρθρων στο Instapaper",
     "form.integration.instapaper_password": "Κωδικός Πρόσβασης Instapaper",
     "form.integration.instapaper_username": "Όνομα Χρήστη Instapaper",

+ 4 - 0
internal/locale/translations/en_US.json

@@ -246,6 +246,10 @@
     "form.integration.googlereader_endpoint": "Google Reader API endpoint:",
     "form.integration.googlereader_password": "Google Reader Password",
     "form.integration.googlereader_username": "Google Reader Username",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "Save entries to Instapaper",
     "form.integration.instapaper_password": "Instapaper Password",
     "form.integration.instapaper_username": "Instapaper Username",

+ 4 - 0
internal/locale/translations/es_ES.json

@@ -246,6 +246,10 @@
     "form.integration.googlereader_endpoint": "Acceso API de Google Reader:",
     "form.integration.googlereader_password": "Contraseña de Google Reader",
     "form.integration.googlereader_username": "Nombre de usuario de Google Reader",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "Enviar artículos a Instapaper",
     "form.integration.instapaper_password": "Contraseña de Instapaper",
     "form.integration.instapaper_username": "Nombre de usuario de Instapaper",

+ 4 - 0
internal/locale/translations/fi_FI.json

@@ -246,6 +246,10 @@
     "form.integration.googlereader_endpoint": "Google Reader API -päätepiste:",
     "form.integration.googlereader_password": "Google-lukijan salasana",
     "form.integration.googlereader_username": "Google-lukijan käyttäjätunnus",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "Tallenna artikkelit Instapaperiin",
     "form.integration.instapaper_password": "Instapaper-salasana",
     "form.integration.instapaper_username": "Instapaper-käyttäjätunnus",

+ 4 - 0
internal/locale/translations/fr_FR.json

@@ -246,6 +246,10 @@
     "form.integration.googlereader_endpoint": "Point de terminaison de l'API Google Reader :",
     "form.integration.googlereader_password": "Mot de passe pour l'API de Google Reader",
     "form.integration.googlereader_username": "Nom d'utilisateur pour l'API de Google Reader",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "Sauvegarder les articles vers Instapaper",
     "form.integration.instapaper_password": "Mot de passe Instapaper",
     "form.integration.instapaper_username": "Nom d'utilisateur Instapaper",

+ 4 - 0
internal/locale/translations/gl_ES.json

@@ -246,6 +246,10 @@
     "form.integration.googlereader_endpoint": "Punto de acceso de Google Reader API:",
     "form.integration.googlereader_password": "Contrasinal Google Reader",
     "form.integration.googlereader_username": "Identificador Google Reader",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "Gardar entradas en Instapaper",
     "form.integration.instapaper_password": "Contrasinal Instapaper",
     "form.integration.instapaper_username": "Identificador Instapaper",

+ 4 - 0
internal/locale/translations/hi_IN.json

@@ -246,6 +246,10 @@
     "form.integration.googlereader_endpoint": "गूगल रीडर एपीआई समापन बिंदु:",
     "form.integration.googlereader_password": "गूगल रीडर पासवर्ड",
     "form.integration.googlereader_username": "गूगल रीडर उपयोगकर्ता नाम",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "विषय-वस्तु को इंस्टापेपर में सहेजें",
     "form.integration.instapaper_password": "इंस्टापेपर पासवर्ड",
     "form.integration.instapaper_username": "इंस्टापेपर यूजरनेम",

+ 4 - 0
internal/locale/translations/id_ID.json

@@ -243,6 +243,10 @@
     "form.integration.googlereader_endpoint": "Titik URL API Google Reader:",
     "form.integration.googlereader_password": "Kata Sandi Google Reader",
     "form.integration.googlereader_username": "Nama Pengguna Google Reader",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "Simpan artikel ke Instapaper",
     "form.integration.instapaper_password": "Kata Sandi Instapaper",
     "form.integration.instapaper_username": "Nama Pengguna Instapaper",

+ 4 - 0
internal/locale/translations/it_IT.json

@@ -246,6 +246,10 @@
     "form.integration.googlereader_endpoint": "Endpoint dell'API di Google Reader:",
     "form.integration.googlereader_password": "Password dell'account Google Reader",
     "form.integration.googlereader_username": "Nome utente dell'account Google Reader",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "Salva gli articoli su Instapaper",
     "form.integration.instapaper_password": "Password dell'account Instapaper",
     "form.integration.instapaper_username": "Nome utente dell'account Instapaper",

+ 4 - 0
internal/locale/translations/ja_JP.json

@@ -243,6 +243,10 @@
     "form.integration.googlereader_endpoint": "Google Reader APIエンドポイント:",
     "form.integration.googlereader_password": "Google Reader のパスワード",
     "form.integration.googlereader_username": "Google Reader のユーザー名",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "Instapaper に記事を保存する",
     "form.integration.instapaper_password": "Instapaper のパスワード",
     "form.integration.instapaper_username": "Instapaper のユーザー名",

+ 4 - 0
internal/locale/translations/ko_KR.json

@@ -243,6 +243,10 @@
     "form.integration.googlereader_endpoint": "Google Reader API 엔드포인트:",
     "form.integration.googlereader_password": "Google Reader 비밀번호",
     "form.integration.googlereader_username": "Google Reader 사용자명",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "Instapaper에 게시물 저장",
     "form.integration.instapaper_password": "Instapaper 비밀번호",
     "form.integration.instapaper_username": "Instapaper 사용자명",

+ 4 - 0
internal/locale/translations/nan_Latn_pehoeji.json

@@ -243,6 +243,10 @@
     "form.integration.googlereader_endpoint": "Google Reader API thâu:",
     "form.integration.googlereader_password": "Google Reader bi̍t-bé",
     "form.integration.googlereader_username": "Google Reader Kháu-chō miâ",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "Pó-chûn siau-sit kàu Instapaper",
     "form.integration.instapaper_password": "Instapaper bi̍t-bé",
     "form.integration.instapaper_username": "Instapaper Kháu-chō miâ",

+ 4 - 0
internal/locale/translations/nl_NL.json

@@ -246,6 +246,10 @@
     "form.integration.googlereader_endpoint": "Google Reader API-endpoint:",
     "form.integration.googlereader_password": "Google Reader wachtwoord",
     "form.integration.googlereader_username": "Google Reader gebruikersnaam",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "Artikelen opslaan in Instapaper",
     "form.integration.instapaper_password": "Instapaper wachtwoord",
     "form.integration.instapaper_username": "Instapaper gebruikersnaam",

+ 4 - 0
internal/locale/translations/pl_PL.json

@@ -249,6 +249,10 @@
     "form.integration.googlereader_endpoint": "Punkt końcowy API Google Reader:",
     "form.integration.googlereader_password": "Hasło do Google Reader",
     "form.integration.googlereader_username": "Login do Google Reader",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "Zapisuj wpisy w Instapaper",
     "form.integration.instapaper_password": "Hasło do Instapaper",
     "form.integration.instapaper_username": "Login do Instapaper",

+ 4 - 0
internal/locale/translations/pt_BR.json

@@ -246,6 +246,10 @@
     "form.integration.googlereader_endpoint": "Endpoint da API do Google Reader:",
     "form.integration.googlereader_password": "Senha do Google Reader",
     "form.integration.googlereader_username": "Nome de usuário do Google Reader",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "Salvar itens no Instapaper",
     "form.integration.instapaper_password": "Senha do Instapaper",
     "form.integration.instapaper_username": "Nome do usuário do Instapaper",

+ 4 - 0
internal/locale/translations/ro_RO.json

@@ -249,6 +249,10 @@
     "form.integration.googlereader_endpoint": "Punct acces API Google Reader:",
     "form.integration.googlereader_password": "Parolă Google Reader",
     "form.integration.googlereader_username": "Utilizator Google Reader",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "Salvează înregistrările pe Instapaper",
     "form.integration.instapaper_password": "Parolă Instapaper",
     "form.integration.instapaper_username": "Utilizator Instapaper",

+ 4 - 0
internal/locale/translations/ru_RU.json

@@ -249,6 +249,10 @@
     "form.integration.googlereader_endpoint": "Конечная точка Google Reader API:",
     "form.integration.googlereader_password": "Пароль Google Reader",
     "form.integration.googlereader_username": "Имя пользователя Google Reader",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "Сохранять статьи в Instapaper",
     "form.integration.instapaper_password": "Пароль Instapaper",
     "form.integration.instapaper_username": "Имя пользователя Instapaper",

+ 4 - 0
internal/locale/translations/tr_TR.json

@@ -246,6 +246,10 @@
     "form.integration.googlereader_endpoint": "Google Reader API uç noktası:",
     "form.integration.googlereader_password": "Google Reader Parolası",
     "form.integration.googlereader_username": "Google Reader Kullanıcı Adı",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "Makaleleri Instapaper'a kaydet",
     "form.integration.instapaper_password": "Instapaper Parolası",
     "form.integration.instapaper_username": "Instapaper Kullanıcı Adı",

+ 4 - 0
internal/locale/translations/uk_UA.json

@@ -249,6 +249,10 @@
     "form.integration.googlereader_endpoint": "Адреса доступу API Google Reader:",
     "form.integration.googlereader_password": "Пароль Google Reader",
     "form.integration.googlereader_username": "Ім’я користувача Google Reader",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "Зберігати статті до Instapaper",
     "form.integration.instapaper_password": "Пароль Instapaper",
     "form.integration.instapaper_username": "Ім’я користувача Instapaper",

+ 4 - 0
internal/locale/translations/zh_CN.json

@@ -243,6 +243,10 @@
     "form.integration.googlereader_endpoint": "Google Reader API 端点:",
     "form.integration.googlereader_password": "Google Reader 密码",
     "form.integration.googlereader_username": "Google Reader 用户名",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "保存条目到 Instapaper",
     "form.integration.instapaper_password": "Instapaper 密码",
     "form.integration.instapaper_username": "Instapaper 用户名",

+ 4 - 0
internal/locale/translations/zh_TW.json

@@ -243,6 +243,10 @@
     "form.integration.googlereader_endpoint": "Google Reader API 端點:",
     "form.integration.googlereader_password": "Google Reader 密碼",
     "form.integration.googlereader_username": "Google Reader 使用者名稱",
+    "form.integration.gotify_activate": "Push entries to Gotify",
+    "form.integration.gotify_priority": "Gotify priority",
+    "form.integration.gotify_server_url": "Gotify server URL",
+    "form.integration.gotify_token": "Gotify token",
     "form.integration.instapaper_activate": "儲存文章到 Instapaper",
     "form.integration.instapaper_password": "Instapaper 密碼",
     "form.integration.instapaper_username": "Instapaper 使用者名稱",

+ 4 - 0
internal/model/integration.go

@@ -127,4 +127,8 @@ type Integration struct {
 	PushoverDevice                   string
 	PushoverPrefix                   string
 	ArchiveorgEnabled                bool
+	GotifyEnabled                    bool
+	GotifyURL                        string
+	GotifyToken                      string
+	GotifyPriority                   int
 }

+ 19 - 3
internal/storage/integration.go

@@ -231,7 +231,11 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
 			linktaco_org_slug,
 			linktaco_tags,
 			linktaco_visibility,
-			archiveorg_enabled
+			archiveorg_enabled,
+			gotify_enabled,
+			gotify_url,
+			gotify_token,
+			gotify_priority
 		FROM
 			integrations
 		WHERE
@@ -361,6 +365,10 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
 		&integration.LinktacoTags,
 		&integration.LinktacoVisibility,
 		&integration.ArchiveorgEnabled,
+		&integration.GotifyEnabled,
+		&integration.GotifyURL,
+		&integration.GotifyToken,
+		&integration.GotifyPriority,
 	)
 	switch {
 	case errors.Is(err, sql.ErrNoRows):
@@ -498,9 +506,13 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
 			linktaco_visibility=$118,
 			archiveorg_enabled=$119,
 			linkwarden_collection_id=$120,
-			readeck_push_enabled=$121
+			readeck_push_enabled=$121,
+			gotify_enabled=$122,
+			gotify_url=$123,
+			gotify_token=$124,
+			gotify_priority=$125
 		WHERE
-			user_id=$122
+			user_id=$126
 	`
 	_, err := s.db.Exec(
 		query,
@@ -625,6 +637,10 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
 		integration.ArchiveorgEnabled,
 		integration.LinkwardenCollectionID,
 		integration.ReadeckPushEnabled,
+		integration.GotifyEnabled,
+		integration.GotifyURL,
+		integration.GotifyToken,
+		integration.GotifyPriority,
 		integration.UserID,
 	)
 

+ 22 - 0
internal/template/templates/views/integrations.html

@@ -165,6 +165,28 @@
         </div>
     </details>
 
+    <details {{ if .form.GotifyEnabled }}open{{ end }}>
+        <summary>Gotify</summary>
+        <div class="form-section">
+            <label>
+                <input type="checkbox" name="gotify_enabled" value="1" {{ if .form.GotifyEnabled }}checked{{ end }}> {{ t "form.integration.gotify_activate" }}
+            </label>
+
+            <label for="form-gotify-url">{{ t "form.integration.gotify_server_url" }}</label>
+            <input type="url" name="gotify_url" id="form-gotify-url" value="{{ .form.GotifyURL }}" placeholder="https://gotify.example.com" spellcheck="false">
+
+            <label for="form-gotify-token">{{ t "form.integration.gotify_token" }}</label>
+            <input type="password" name="gotify_token" id="form-gotify-token" value="{{ .form.GotifyToken }}" spellcheck="false" autocomplete="new-password">
+
+            <label for="form-gotify-priority">{{ t "form.integration.gotify_priority" }}</label>
+            <input type="number" name="gotify_priority" id="form-gotify-priority" value="{{ .form.GotifyPriority }}" min="0" max="10">
+
+            <div class="buttons">
+                <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
+            </div>
+        </div>
+    </details>
+
     <details {{ if .form.InstapaperEnabled }}open{{ end }}>
         <summary>Instapaper</summary>
         <div class="form-section">

+ 14 - 0
internal/ui/form/integration.go

@@ -134,6 +134,10 @@ type IntegrationForm struct {
 	PushoverDevice                   string
 	PushoverPrefix                   string
 	ArchiveorgEnabled                bool
+	GotifyEnabled                    bool
+	GotifyURL                        string
+	GotifyToken                      string
+	GotifyPriority                   int
 }
 
 // Merge copy form values to the model.
@@ -256,6 +260,10 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
 	integration.PushoverDevice = i.PushoverDevice
 	integration.PushoverPrefix = i.PushoverPrefix
 	integration.ArchiveorgEnabled = i.ArchiveorgEnabled
+	integration.GotifyEnabled = i.GotifyEnabled
+	integration.GotifyURL = i.GotifyURL
+	integration.GotifyToken = i.GotifyToken
+	integration.GotifyPriority = i.GotifyPriority
 }
 
 // ValidateGoogleReader ensures the Google Reader integration is not enabled without credentials.
@@ -271,6 +279,8 @@ func (i IntegrationForm) ValidateGoogleReader(storedPasswordHash string) *locale
 
 // NewIntegrationForm returns a new IntegrationForm.
 func NewIntegrationForm(r *http.Request) *IntegrationForm {
+	gotifyPriority, _ := strconv.Atoi(r.FormValue("gotify_priority"))
+
 	return &IntegrationForm{
 		PinboardEnabled:                  r.FormValue("pinboard_enabled") == "1",
 		PinboardToken:                    r.FormValue("pinboard_token"),
@@ -392,6 +402,10 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm {
 		PushoverDevice:                   r.FormValue("pushover_device"),
 		PushoverPrefix:                   r.FormValue("pushover_prefix"),
 		ArchiveorgEnabled:                r.FormValue("archiveorg_enabled") == "1",
+		GotifyEnabled:                    r.FormValue("gotify_enabled") == "1",
+		GotifyURL:                        r.FormValue("gotify_url"),
+		GotifyToken:                      r.FormValue("gotify_token"),
+		GotifyPriority:                   gotifyPriority,
 	}
 }
 

+ 4 - 0
internal/ui/integration_show.go

@@ -145,6 +145,10 @@ func (h *handler) showIntegrationPage(w http.ResponseWriter, r *http.Request) {
 		PushoverDevice:                   integration.PushoverDevice,
 		PushoverPrefix:                   integration.PushoverPrefix,
 		ArchiveorgEnabled:                integration.ArchiveorgEnabled,
+		GotifyEnabled:                    integration.GotifyEnabled,
+		GotifyURL:                        integration.GotifyURL,
+		GotifyToken:                      integration.GotifyToken,
+		GotifyPriority:                   integration.GotifyPriority,
 	}
 
 	view := view.New(h.tpl, r)