Kaynağa Gözat

Use stdlib HTTP client for third-party integrations

Frédéric Guillot 2 yıl önce
ebeveyn
işleme
5e520ca5bf
34 değiştirilmiş dosya ile 496 ekleme ve 345 silme
  1. 37 30
      internal/integration/apprise/apprise.go
  2. 0 9
      internal/integration/apprise/wrapper.go
  3. 42 24
      internal/integration/espial/espial.go
  4. 25 15
      internal/integration/instapaper/instapaper.go
  5. 11 11
      internal/integration/integration.go
  6. 38 23
      internal/integration/linkding/linkding.go
  7. 56 24
      internal/integration/notion/notion.go
  8. 0 19
      internal/integration/notion/wrapper.go
  9. 39 25
      internal/integration/nunuxkeeper/nunuxkeeper.go
  10. 24 13
      internal/integration/pinboard/pinboard.go
  11. 69 39
      internal/integration/pocket/connector.go
  12. 37 21
      internal/integration/pocket/pocket.go
  13. 30 27
      internal/integration/readwise/readwise.go
  14. 2 2
      internal/integration/shaarli/shaarli.go
  15. 3 4
      internal/integration/shiori/shiori.go
  16. 63 39
      internal/integration/wallabag/wallabag.go
  17. 1 1
      internal/locale/translations/de_DE.json
  18. 1 1
      internal/locale/translations/el_EL.json
  19. 1 1
      internal/locale/translations/en_US.json
  20. 1 1
      internal/locale/translations/es_ES.json
  21. 1 1
      internal/locale/translations/fi_FI.json
  22. 3 3
      internal/locale/translations/fr_FR.json
  23. 1 1
      internal/locale/translations/hi_IN.json
  24. 1 1
      internal/locale/translations/id_ID.json
  25. 1 1
      internal/locale/translations/it_IT.json
  26. 1 1
      internal/locale/translations/ja_JP.json
  27. 1 1
      internal/locale/translations/nl_NL.json
  28. 1 1
      internal/locale/translations/pl_PL.json
  29. 1 1
      internal/locale/translations/pt_BR.json
  30. 1 1
      internal/locale/translations/ru_RU.json
  31. 1 1
      internal/locale/translations/tr_TR.json
  32. 1 1
      internal/locale/translations/uk_UA.json
  33. 1 1
      internal/locale/translations/zh_CN.json
  34. 1 1
      internal/locale/translations/zh_TW.json

+ 37 - 30
internal/integration/apprise/apprise.go

@@ -4,57 +4,64 @@
 package apprise
 
 import (
+	"bytes"
+	"encoding/json"
 	"fmt"
-	"net"
-	"strings"
+	"net/http"
 	"time"
 
-	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/urllib"
+	"miniflux.app/v2/internal/version"
 )
 
-const defaultClientTimeout = 1 * time.Second
+const defaultClientTimeout = 10 * time.Second
 
-// Client represents a Apprise client.
 type Client struct {
 	servicesURL string
 	baseURL     string
 }
 
-// NewClient returns a new Apprise client.
 func NewClient(serviceURL, baseURL string) *Client {
 	return &Client{serviceURL, baseURL}
 }
 
-// PushEntry pushes entry to apprise
-func (c *Client) PushEntry(entry *model.Entry) error {
+func (c *Client) SendNotification(entry *model.Entry) error {
 	if c.baseURL == "" || c.servicesURL == "" {
 		return fmt.Errorf("apprise: missing base URL or service URL")
 	}
-	_, err := net.DialTimeout("tcp", c.baseURL, defaultClientTimeout)
+
+	message := "[" + entry.Title + "]" + "(" + entry.URL + ")" + "\n\n"
+	apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/notify")
+	if err != nil {
+		return fmt.Errorf(`apprise: invalid API endpoint: %v`, err)
+	}
+
+	requestBody, err := json.Marshal(map[string]any{
+		"urls": c.servicesURL,
+		"body": message,
+	})
+	if err != nil {
+		return fmt.Errorf("apprise: unable to encode request body: %v", err)
+	}
+
+	request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
 	if err != nil {
-		apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/notify")
-		if err != nil {
-			return fmt.Errorf(`apprise: invalid API endpoint: %v`, err)
-		}
-
-		clt := client.New(apiEndpoint)
-		message := "[" + entry.Title + "]" + "(" + entry.URL + ")" + "\n\n"
-		data := &Data{
-			Urls: c.servicesURL,
-			Body: message,
-		}
-		response, error := clt.PostJSON(data)
-		if error != nil {
-			return fmt.Errorf("apprise: ending message failed: %v", error)
-		}
-
-		if response.HasServerFailure() {
-			return fmt.Errorf("apprise: request failed, status=%d", response.StatusCode)
-		}
-	} else {
-		return fmt.Errorf("%s %s %s", c.baseURL, "responding on port:", strings.Split(c.baseURL, ":")[1])
+		return fmt.Errorf("apprise: unable to create request: %v", err)
+	}
+
+	request.Header.Set("Content-Type", "application/json")
+	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
+
+	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	response, err := httpClient.Do(request)
+	if err != nil {
+		return fmt.Errorf("apprise: unable to send request: %v", err)
+	}
+	defer response.Body.Close()
+
+	if response.StatusCode >= 400 {
+		return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
 	}
 
 	return nil

+ 0 - 9
internal/integration/apprise/wrapper.go

@@ -1,9 +0,0 @@
-// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package apprise
-
-type Data struct {
-	Urls string `json:"urls"`
-	Body string `json:"body"`
-}

+ 42 - 24
internal/integration/espial/espial.go

@@ -4,59 +4,77 @@
 package espial // import "miniflux.app/v2/internal/integration/espial"
 
 import (
+	"bytes"
+	"encoding/json"
 	"fmt"
+	"net/http"
+	"time"
 
-	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/urllib"
+	"miniflux.app/v2/internal/version"
 )
 
-// Document structure of an Espial document
-type Document struct {
-	Title  string `json:"title,omitempty"`
-	Url    string `json:"url,omitempty"`
-	ToRead bool   `json:"toread,omitempty"`
-	Tags   string `json:"tags,omitempty"`
-}
+const defaultClientTimeout = 10 * time.Second
 
-// Client represents an Espial client.
 type Client struct {
 	baseURL string
 	apiKey  string
 }
 
-// NewClient returns a new Espial client.
 func NewClient(baseURL, apiKey string) *Client {
 	return &Client{baseURL: baseURL, apiKey: apiKey}
 }
 
-// AddEntry sends an entry to Espial.
-func (c *Client) AddEntry(link, title, content, tags string) error {
+func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
 	if c.baseURL == "" || c.apiKey == "" {
 		return fmt.Errorf("espial: missing base URL or API key")
 	}
 
-	doc := &Document{
-		Title:  title,
-		Url:    link,
+	apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/add")
+	if err != nil {
+		return fmt.Errorf("espial: invalid API endpoint: %v", err)
+	}
+
+	requestBody, err := json.Marshal(&espialDocument{
+		Title:  entryTitle,
+		Url:    entryURL,
 		ToRead: true,
-		Tags:   tags,
+		Tags:   espialTags,
+	})
+
+	if err != nil {
+		return fmt.Errorf("espial: unable to encode request body: %v", err)
 	}
 
-	apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/add")
+	request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
 	if err != nil {
-		return fmt.Errorf(`espial: invalid API endpoint: %v`, err)
+		return fmt.Errorf("espial: unable to create request: %v", err)
 	}
 
-	clt := client.New(apiEndpoint)
-	clt.WithAuthorization("ApiKey " + c.apiKey)
-	response, err := clt.PostJSON(doc)
+	request.Header.Set("Content-Type", "application/json")
+	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
+	request.Header.Set("Authorization", "ApiKey "+c.apiKey)
+
+	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	response, err := httpClient.Do(request)
 	if err != nil {
-		return fmt.Errorf("espial: unable to send entry: %v", err)
+		return fmt.Errorf("espial: unable to send request: %v", err)
 	}
+	defer response.Body.Close()
 
-	if response.HasServerFailure() {
-		return fmt.Errorf("espial: unable to send entry, status=%d", response.StatusCode)
+	if response.StatusCode != http.StatusCreated {
+		responseBody := new(bytes.Buffer)
+		responseBody.ReadFrom(response.Body)
+
+		return fmt.Errorf("espial: unable to create link: url=%s status=%d body=%s", apiEndpoint, response.StatusCode, responseBody.String())
 	}
 
 	return nil
 }
+
+type espialDocument struct {
+	Title  string `json:"title,omitempty"`
+	Url    string `json:"url,omitempty"`
+	ToRead bool   `json:"toread,omitempty"`
+	Tags   string `json:"tags,omitempty"`
+}

+ 25 - 15
internal/integration/instapaper/instapaper.go

@@ -5,42 +5,52 @@ package instapaper // import "miniflux.app/v2/internal/integration/instapaper"
 
 import (
 	"fmt"
+	"net/http"
 	"net/url"
+	"time"
 
-	"miniflux.app/v2/internal/http/client"
+	"miniflux.app/v2/internal/version"
 )
 
-// Client represents an Instapaper client.
+const defaultClientTimeout = 10 * time.Second
+
 type Client struct {
 	username string
 	password string
 }
 
-// NewClient returns a new Instapaper client.
 func NewClient(username, password string) *Client {
 	return &Client{username: username, password: password}
 }
 
-// AddURL sends a link to Instapaper.
-func (c *Client) AddURL(link, title string) error {
+func (c *Client) AddURL(entryURL, entryTitle string) error {
 	if c.username == "" || c.password == "" {
-		return fmt.Errorf("instapaper: missing credentials")
+		return fmt.Errorf("instapaper: missing username or password")
 	}
 
 	values := url.Values{}
-	values.Add("url", link)
-	values.Add("title", title)
+	values.Add("url", entryURL)
+	values.Add("title", entryTitle)
+
+	apiEndpoint := "https://www.instapaper.com/api/add?" + values.Encode()
+	request, err := http.NewRequest(http.MethodGet, apiEndpoint, nil)
+	if err != nil {
+		return fmt.Errorf("instapaper: unable to create request: %v", err)
+	}
+
+	request.SetBasicAuth(c.username, c.password)
+	request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 
-	apiURL := "https://www.instapaper.com/api/add?" + values.Encode()
-	clt := client.New(apiURL)
-	clt.WithCredentials(c.username, c.password)
-	response, err := clt.Get()
+	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	response, err := httpClient.Do(request)
 	if err != nil {
-		return fmt.Errorf("instapaper: unable to send url: %v", err)
+		return fmt.Errorf("instapaper: unable to send request: %v", err)
 	}
+	defer response.Body.Close()
 
-	if response.HasServerFailure() {
-		return fmt.Errorf("instapaper: unable to send url, status=%d", response.StatusCode)
+	if response.StatusCode != http.StatusCreated {
+		return fmt.Errorf("instapaper: unable to add URL: url=%s status=%d", apiEndpoint, response.StatusCode)
 	}
 
 	return nil

+ 11 - 11
internal/integration/integration.go

@@ -29,7 +29,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
 		logger.Debug("[Integration] Sending entry #%d %q for user #%d to Pinboard", entry.ID, entry.URL, integration.UserID)
 
 		client := pinboard.NewClient(integration.PinboardToken)
-		err := client.AddBookmark(
+		err := client.CreateBookmark(
 			entry.URL,
 			entry.Title,
 			integration.PinboardTags,
@@ -62,7 +62,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
 			integration.WallabagOnlyURL,
 		)
 
-		if err := client.AddEntry(entry.URL, entry.Title, entry.Content); err != nil {
+		if err := client.CreateEntry(entry.URL, entry.Title, entry.Content); err != nil {
 			logger.Error("[Integration] UserID #%d: %v", integration.UserID, err)
 		}
 	}
@@ -74,7 +74,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
 			integration.NotionToken,
 			integration.NotionPageID,
 		)
-		if err := client.AddEntry(entry.URL, entry.Title); err != nil {
+		if err := client.UpdateDocument(entry.URL, entry.Title); err != nil {
 			logger.Error("[Integration] UserID #%d: %v", integration.UserID, err)
 		}
 	}
@@ -100,8 +100,8 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
 			integration.EspialAPIKey,
 		)
 
-		if err := client.AddEntry(entry.URL, entry.Title, entry.Content, integration.EspialTags); err != nil {
-			logger.Error("[Integration] UserID #%d: %v", integration.UserID, err)
+		if err := client.CreateLink(entry.URL, entry.Title, integration.EspialTags); err != nil {
+			logger.Error("[Integration] Unable to send entry #%d to Espial for user #%d: %v", entry.ID, integration.UserID, err)
 		}
 	}
 
@@ -123,7 +123,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
 			integration.LinkdingTags,
 			integration.LinkdingMarkAsUnread,
 		)
-		if err := client.AddEntry(entry.Title, entry.URL); err != nil {
+		if err := client.CreateBookmark(entry.URL, entry.Title); err != nil {
 			logger.Error("[Integration] UserID #%d: %v", integration.UserID, err)
 		}
 	}
@@ -135,7 +135,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
 			integration.ReadwiseAPIKey,
 		)
 
-		if err := client.AddEntry(entry.URL); err != nil {
+		if err := client.CreateDocument(entry.URL); err != nil {
 			logger.Error("[Integration] UserID #%d: %v", integration.UserID, err)
 		}
 	}
@@ -149,7 +149,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
 			integration.ShioriPassword,
 		)
 
-		if err := client.AddBookmark(entry.URL, entry.Title); err != nil {
+		if err := client.CreateBookmark(entry.URL, entry.Title); err != nil {
 			logger.Error("[Integration] Unable to send entry #%d to Shiori for user #%d: %v", entry.ID, integration.UserID, err)
 		}
 	}
@@ -162,7 +162,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
 			integration.ShaarliAPISecret,
 		)
 
-		if err := client.AddLink(entry.URL, entry.Title); err != nil {
+		if err := client.CreateLink(entry.URL, entry.Title); err != nil {
 			logger.Error("[Integration] Unable to send entry #%d to Shaarli for user #%d: %v", entry.ID, integration.UserID, err)
 		}
 	}
@@ -197,8 +197,8 @@ func PushEntry(entry *model.Entry, integration *model.Integration) {
 			integration.AppriseServicesURL,
 			integration.AppriseURL,
 		)
-		err := client.PushEntry(entry)
-		if err != nil {
+
+		if err := client.SendNotification(entry); err != nil {
 			logger.Error("[Integration] push entry to apprise failed: %v", err)
 		}
 	}

+ 38 - 23
internal/integration/linkding/linkding.go

@@ -4,22 +4,19 @@
 package linkding // import "miniflux.app/v2/internal/integration/linkding"
 
 import (
+	"bytes"
+	"encoding/json"
 	"fmt"
+	"net/http"
 	"strings"
+	"time"
 
-	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/urllib"
+	"miniflux.app/v2/internal/version"
 )
 
-// Document structure of a Linkding document
-type Document struct {
-	Url      string   `json:"url,omitempty"`
-	Title    string   `json:"title,omitempty"`
-	TagNames []string `json:"tag_names,omitempty"`
-	Unread   bool     `json:"unread,omitempty"`
-}
+const defaultClientTimeout = 10 * time.Second
 
-// Client represents an Linkding client.
 type Client struct {
 	baseURL string
 	apiKey  string
@@ -27,43 +24,61 @@ type Client struct {
 	unread  bool
 }
 
-// NewClient returns a new Linkding client.
 func NewClient(baseURL, apiKey, tags string, unread bool) *Client {
 	return &Client{baseURL: baseURL, apiKey: apiKey, tags: tags, unread: unread}
 }
 
-// AddEntry sends an entry to Linkding.
-func (c *Client) AddEntry(title, entryURL string) error {
+func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
 	if c.baseURL == "" || c.apiKey == "" {
-		return fmt.Errorf("linkding: missing credentials")
+		return fmt.Errorf("linkding: missing base URL or API key")
 	}
 
 	tagsSplitFn := func(c rune) bool {
 		return c == ',' || c == ' '
 	}
 
-	doc := &Document{
+	apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/bookmarks/")
+	if err != nil {
+		return fmt.Errorf(`linkding: invalid API endpoint: %v`, err)
+	}
+
+	requestBody, err := json.Marshal(&linkdingBookmark{
 		Url:      entryURL,
-		Title:    title,
+		Title:    entryTitle,
 		TagNames: strings.FieldsFunc(c.tags, tagsSplitFn),
 		Unread:   c.unread,
+	})
+
+	if err != nil {
+		return fmt.Errorf("linkding: unable to encode request body: %v", err)
 	}
 
-	apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/bookmarks/")
+	request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
 	if err != nil {
-		return fmt.Errorf(`linkding: invalid API endpoint: %v`, err)
+		return fmt.Errorf("linkding: unable to create request: %v", err)
 	}
 
-	clt := client.New(apiEndpoint)
-	clt.WithAuthorization("Token " + c.apiKey)
-	response, err := clt.PostJSON(doc)
+	request.Header.Set("Content-Type", "application/json")
+	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
+	request.Header.Set("Authorization", "Token "+c.apiKey)
+
+	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	response, err := httpClient.Do(request)
 	if err != nil {
-		return fmt.Errorf("linkding: unable to send entry: %v", err)
+		return fmt.Errorf("linkding: unable to send request: %v", err)
 	}
+	defer response.Body.Close()
 
-	if response.HasServerFailure() {
-		return fmt.Errorf("linkding: unable to send entry, status=%d", response.StatusCode)
+	if response.StatusCode >= 400 {
+		return fmt.Errorf("linkding: unable to create bookmark: url=%s status=%d", apiEndpoint, response.StatusCode)
 	}
 
 	return nil
 }
+
+type linkdingBookmark struct {
+	Url      string   `json:"url,omitempty"`
+	Title    string   `json:"title,omitempty"`
+	TagNames []string `json:"tag_names,omitempty"`
+	Unread   bool     `json:"unread,omitempty"`
+}

+ 56 - 24
internal/integration/notion/notion.go

@@ -4,51 +4,83 @@
 package notion
 
 import (
+	"bytes"
+	"encoding/json"
 	"fmt"
+	"net/http"
+	"time"
 
-	"miniflux.app/v2/internal/http/client"
+	"miniflux.app/v2/internal/version"
 )
 
-// Client represents a Notion client.
+const defaultClientTimeout = 10 * time.Second
+
 type Client struct {
-	token  string
-	pageID string
+	apiToken string
+	pageID   string
 }
 
-// NewClient returns a new Notion client.
-func NewClient(token, pageID string) *Client {
-	return &Client{token, pageID}
+func NewClient(apiToken, pageID string) *Client {
+	return &Client{apiToken, pageID}
 }
 
-func (c *Client) AddEntry(entryURL string, entryTitle string) error {
-	if c.token == "" || c.pageID == "" {
-		return fmt.Errorf("notion: missing credentials")
+func (c *Client) UpdateDocument(entryURL string, entryTitle string) error {
+	if c.apiToken == "" || c.pageID == "" {
+		return fmt.Errorf("notion: missing API token or page ID")
 	}
-	clt := client.New("https://api.notion.com/v1/blocks/" + c.pageID + "/children")
-	block := &Data{
-		Children: []Block{
+
+	apiEndpoint := "https://api.notion.com/v1/blocks/" + c.pageID + "/children"
+	requestBody, err := json.Marshal(&notionDocument{
+		Children: []block{
 			{
 				Object: "block",
 				Type:   "bookmark",
-				Bookmark: Bookmark{
-					Caption: []interface{}{},
+				Bookmark: bookmarkObject{
+					Caption: []any{},
 					URL:     entryURL,
 				},
 			},
 		},
+	})
+	if err != nil {
+		return fmt.Errorf("notion: unable to encode request body: %v", err)
 	}
-	clt.WithAuthorization("Bearer " + c.token)
-	customHeaders := map[string]string{
-		"Notion-Version": "2022-06-28",
+
+	request, err := http.NewRequest(http.MethodPatch, apiEndpoint, bytes.NewReader(requestBody))
+	if err != nil {
+		return fmt.Errorf("notion: unable to create request: %v", err)
 	}
-	clt.WithCustomHeaders(customHeaders)
-	response, error := clt.PatchJSON(block)
-	if error != nil {
-		return fmt.Errorf("notion: unable to patch entry: %v", error)
+
+	request.Header.Set("Content-Type", "application/json")
+	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
+	request.Header.Set("Notion-Version", "2022-06-28")
+	request.Header.Set("Authorization", "Bearer "+c.apiToken)
+
+	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	response, err := httpClient.Do(request)
+	if err != nil {
+		return fmt.Errorf("notion: unable to send request: %v", err)
 	}
+	defer response.Body.Close()
 
-	if response.HasServerFailure() {
-		return fmt.Errorf("notion: request failed, status=%d", response.StatusCode)
+	if response.StatusCode != http.StatusOK {
+		return fmt.Errorf("notion: unable to update document: url=%s status=%d", apiEndpoint, response.StatusCode)
 	}
+
 	return nil
 }
+
+type notionDocument struct {
+	Children []block `json:"children"`
+}
+
+type block struct {
+	Object   string         `json:"object"`
+	Type     string         `json:"type"`
+	Bookmark bookmarkObject `json:"bookmark"`
+}
+
+type bookmarkObject struct {
+	Caption []any  `json:"caption"`
+	URL     string `json:"url"`
+}

+ 0 - 19
internal/integration/notion/wrapper.go

@@ -1,19 +0,0 @@
-// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package notion
-
-type Data struct {
-	Children []Block `json:"children"`
-}
-
-type Block struct {
-	Object   string   `json:"object"`
-	Type     string   `json:"type"`
-	Bookmark Bookmark `json:"bookmark"`
-}
-
-type Bookmark struct {
-	Caption []interface{} `json:"caption"` // Assuming the "caption" field can have different types
-	URL     string        `json:"url"`
-}

+ 39 - 25
internal/integration/nunuxkeeper/nunuxkeeper.go

@@ -4,59 +4,73 @@
 package nunuxkeeper // import "miniflux.app/v2/internal/integration/nunuxkeeper"
 
 import (
+	"bytes"
+	"encoding/json"
 	"fmt"
+	"net/http"
+	"time"
 
-	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/urllib"
+	"miniflux.app/v2/internal/version"
 )
 
-// Document structure of a Nununx Keeper document
-type Document struct {
-	Title       string `json:"title,omitempty"`
-	Origin      string `json:"origin,omitempty"`
-	Content     string `json:"content,omitempty"`
-	ContentType string `json:"contentType,omitempty"`
-}
+const defaultClientTimeout = 10 * time.Second
 
-// Client represents an Nunux Keeper client.
 type Client struct {
 	baseURL string
 	apiKey  string
 }
 
-// NewClient returns a new Nunux Keeepr client.
 func NewClient(baseURL, apiKey string) *Client {
 	return &Client{baseURL: baseURL, apiKey: apiKey}
 }
 
-// AddEntry sends an entry to Nunux Keeper.
-func (c *Client) AddEntry(link, title, content string) error {
+func (c *Client) AddEntry(entryURL, entryTitle, entryContent string) error {
 	if c.baseURL == "" || c.apiKey == "" {
-		return fmt.Errorf("nunux-keeper: missing credentials")
+		return fmt.Errorf("nunux-keeper: missing base URL or API key")
+	}
+
+	apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/v2/documents")
+	if err != nil {
+		return fmt.Errorf(`nunux-keeper: invalid API endpoint: %v`, err)
 	}
 
-	doc := &Document{
-		Title:       title,
-		Origin:      link,
-		Content:     content,
+	requestBody, err := json.Marshal(&nunuxKeeperDocument{
+		Title:       entryTitle,
+		Origin:      entryURL,
+		Content:     entryContent,
 		ContentType: "text/html",
+	})
+	if err != nil {
+		return fmt.Errorf("notion: unable to encode request body: %v", err)
 	}
 
-	apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/v2/documents")
+	request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
 	if err != nil {
-		return fmt.Errorf(`nunux-keeper: invalid API endpoint: %v`, err)
+		return fmt.Errorf("nunux-keeper: unable to create request: %v", err)
 	}
 
-	clt := client.New(apiEndpoint)
-	clt.WithCredentials("api", c.apiKey)
-	response, err := clt.PostJSON(doc)
+	request.SetBasicAuth("api", c.apiKey)
+	request.Header.Set("Content-Type", "application/json")
+	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
+
+	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	response, err := httpClient.Do(request)
 	if err != nil {
-		return fmt.Errorf("nunux-keeper: unable to send entry: %v", err)
+		return fmt.Errorf("nunux-keeper: unable to send request: %v", err)
 	}
+	defer response.Body.Close()
 
-	if response.HasServerFailure() {
-		return fmt.Errorf("nunux-keeper: unable to send entry, status=%d", response.StatusCode)
+	if response.StatusCode != http.StatusOK {
+		return fmt.Errorf("nunux-keeper: unable to create document: url=%s status=%d", apiEndpoint, response.StatusCode)
 	}
 
 	return nil
 }
+
+type nunuxKeeperDocument struct {
+	Title       string `json:"title,omitempty"`
+	Origin      string `json:"origin,omitempty"`
+	Content     string `json:"content,omitempty"`
+	ContentType string `json:"contentType,omitempty"`
+}

+ 24 - 13
internal/integration/pinboard/pinboard.go

@@ -5,23 +5,24 @@ package pinboard // import "miniflux.app/v2/internal/integration/pinboard"
 
 import (
 	"fmt"
+	"net/http"
 	"net/url"
+	"time"
 
-	"miniflux.app/v2/internal/http/client"
+	"miniflux.app/v2/internal/version"
 )
 
-// Client represents a Pinboard client.
+const defaultClientTimeout = 10 * time.Second
+
 type Client struct {
 	authToken string
 }
 
-// NewClient returns a new Pinboard client.
 func NewClient(authToken string) *Client {
 	return &Client{authToken: authToken}
 }
 
-// AddBookmark sends a link to Pinboard.
-func (c *Client) AddBookmark(link, title, tags string, markAsUnread bool) error {
+func (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markAsUnread bool) error {
 	if c.authToken == "" {
 		return fmt.Errorf("pinboard: missing auth token")
 	}
@@ -33,19 +34,29 @@ func (c *Client) AddBookmark(link, title, tags string, markAsUnread bool) error
 
 	values := url.Values{}
 	values.Add("auth_token", c.authToken)
-	values.Add("url", link)
-	values.Add("description", title)
-	values.Add("tags", tags)
+	values.Add("url", entryURL)
+	values.Add("description", entryTitle)
+	values.Add("tags", pinboardTags)
 	values.Add("toread", toRead)
 
-	clt := client.New("https://api.pinboard.in/v1/posts/add?" + values.Encode())
-	response, err := clt.Get()
+	apiEndpoint := "https://api.pinboard.in/v1/posts/add?" + values.Encode()
+	request, err := http.NewRequest(http.MethodGet, apiEndpoint, nil)
+	if err != nil {
+		return fmt.Errorf("pinboard: unable to create request: %v", err)
+	}
+
+	request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
+
+	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	response, err := httpClient.Do(request)
 	if err != nil {
-		return fmt.Errorf("pinboard: unable to send bookmark: %v", err)
+		return fmt.Errorf("pinboard: unable to send request: %v", err)
 	}
+	defer response.Body.Close()
 
-	if response.HasServerFailure() {
-		return fmt.Errorf("pinboard: unable to send bookmark, status=%d", response.StatusCode)
+	if response.StatusCode >= 400 {
+		return fmt.Errorf("pinboard: unable to create a bookmark: url=%s status=%d", apiEndpoint, response.StatusCode)
 	}
 
 	return nil

+ 69 - 39
internal/integration/pocket/connector.go

@@ -4,12 +4,13 @@
 package pocket // import "miniflux.app/v2/internal/integration/pocket"
 
 import (
+	"bytes"
+	"encoding/json"
 	"errors"
 	"fmt"
-	"io"
-	"net/url"
+	"net/http"
 
-	"miniflux.app/v2/internal/http/client"
+	"miniflux.app/v2/internal/version"
 )
 
 // Connector manages the authorization flow with Pocket to get a personal access token.
@@ -24,72 +25,82 @@ func NewConnector(consumerKey string) *Connector {
 
 // RequestToken fetches a new request token from Pocket API.
 func (c *Connector) RequestToken(redirectURL string) (string, error) {
-	type req struct {
-		ConsumerKey string `json:"consumer_key"`
-		RedirectURI string `json:"redirect_uri"`
+	apiEndpoint := "https://getpocket.com/v3/oauth/request"
+	requestBody, err := json.Marshal(&createTokenRequest{ConsumerKey: c.consumerKey, RedirectURI: redirectURL})
+	if err != nil {
+		return "", fmt.Errorf("pocket: unable to encode request body: %v", err)
 	}
 
-	clt := client.New("https://getpocket.com/v3/oauth/request")
-	response, err := clt.PostJSON(&req{ConsumerKey: c.consumerKey, RedirectURI: redirectURL})
+	request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
 	if err != nil {
-		return "", fmt.Errorf("pocket: unable to fetch request token: %v", err)
+		return "", fmt.Errorf("pocket: unable to create request: %v", err)
 	}
 
-	if response.HasServerFailure() {
-		return "", fmt.Errorf("pocket: unable to fetch request token, status=%d", response.StatusCode)
-	}
+	request.Header.Set("Content-Type", "application/json")
+	request.Header.Set("X-Accept", "application/json")
+	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 
-	body, err := io.ReadAll(response.Body)
+	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	response, err := httpClient.Do(request)
 	if err != nil {
-		return "", fmt.Errorf("pocket: unable to read response body: %v", err)
+		return "", fmt.Errorf("pocket: unable to send request: %v", err)
 	}
+	defer response.Body.Close()
 
-	values, err := url.ParseQuery(string(body))
-	if err != nil {
-		return "", fmt.Errorf("pocket: unable to parse response: %v", err)
+	if response.StatusCode >= 400 {
+		return "", fmt.Errorf("pocket: unable get request token: url=%s status=%d", apiEndpoint, response.StatusCode)
+	}
+
+	var result createTokenResponse
+	if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
+		return "", fmt.Errorf("pocket: unable to decode response: %v", err)
 	}
 
-	code := values.Get("code")
-	if code == "" {
-		return "", errors.New("pocket: code is empty")
+	if result.Code == "" {
+		return "", errors.New("pocket: request token is empty")
 	}
 
-	return code, nil
+	return result.Code, nil
 }
 
 // AccessToken fetches a new access token once the end-user authorized the application.
 func (c *Connector) AccessToken(requestToken string) (string, error) {
-	type req struct {
-		ConsumerKey string `json:"consumer_key"`
-		Code        string `json:"code"`
+	apiEndpoint := "https://getpocket.com/v3/oauth/authorize"
+	requestBody, err := json.Marshal(&authorizeRequest{ConsumerKey: c.consumerKey, Code: requestToken})
+	if err != nil {
+		return "", fmt.Errorf("pocket: unable to encode request body: %v", err)
 	}
 
-	clt := client.New("https://getpocket.com/v3/oauth/authorize")
-	response, err := clt.PostJSON(&req{ConsumerKey: c.consumerKey, Code: requestToken})
+	request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
 	if err != nil {
-		return "", fmt.Errorf("pocket: unable to fetch access token: %v", err)
+		return "", fmt.Errorf("pocket: unable to create request: %v", err)
 	}
 
-	if response.HasServerFailure() {
-		return "", fmt.Errorf("pocket: unable to fetch access token, status=%d", response.StatusCode)
-	}
+	request.Header.Set("Content-Type", "application/json")
+	request.Header.Set("X-Accept", "application/json")
+	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 
-	body, err := io.ReadAll(response.Body)
+	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	response, err := httpClient.Do(request)
 	if err != nil {
-		return "", fmt.Errorf("pocket: unable to read response body: %v", err)
+		return "", fmt.Errorf("pocket: unable to send request: %v", err)
 	}
+	defer response.Body.Close()
 
-	values, err := url.ParseQuery(string(body))
-	if err != nil {
-		return "", fmt.Errorf("pocket: unable to parse response: %v", err)
+	if response.StatusCode >= 400 {
+		return "", fmt.Errorf("pocket: unable get access token: url=%s status=%d", apiEndpoint, response.StatusCode)
+	}
+
+	var result authorizeReponse
+	if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
+		return "", fmt.Errorf("pocket: unable to decode response: %v", err)
 	}
 
-	token := values.Get("access_token")
-	if token == "" {
-		return "", errors.New("pocket: access_token is empty")
+	if result.AccessToken == "" {
+		return "", errors.New("pocket: access token is empty")
 	}
 
-	return token, nil
+	return result.AccessToken, nil
 }
 
 // AuthorizationURL returns the authorization URL for the end-user.
@@ -100,3 +111,22 @@ func (c *Connector) AuthorizationURL(requestToken, redirectURL string) string {
 		redirectURL,
 	)
 }
+
+type createTokenRequest struct {
+	ConsumerKey string `json:"consumer_key"`
+	RedirectURI string `json:"redirect_uri"`
+}
+
+type createTokenResponse struct {
+	Code string `json:"code"`
+}
+
+type authorizeRequest struct {
+	ConsumerKey string `json:"consumer_key"`
+	Code        string `json:"code"`
+}
+
+type authorizeReponse struct {
+	AccessToken string `json:"access_token"`
+	Username    string `json:"username"`
+}

+ 37 - 21
internal/integration/pocket/pocket.go

@@ -4,51 +4,67 @@
 package pocket // import "miniflux.app/v2/internal/integration/pocket"
 
 import (
+	"bytes"
+	"encoding/json"
 	"fmt"
+	"net/http"
+	"time"
 
-	"miniflux.app/v2/internal/http/client"
+	"miniflux.app/v2/internal/version"
 )
 
-// Client represents a Pocket client.
+const defaultClientTimeout = 10 * time.Second
+
 type Client struct {
 	consumerKey string
 	accessToken string
 }
 
-// NewClient returns a new Pocket client.
 func NewClient(consumerKey, accessToken string) *Client {
 	return &Client{consumerKey, accessToken}
 }
 
-// AddURL sends a single link to Pocket.
-func (c *Client) AddURL(link, title string) error {
+func (c *Client) AddURL(entryURL, entryTitle string) error {
 	if c.consumerKey == "" || c.accessToken == "" {
-		return fmt.Errorf("pocket: missing credentials")
-	}
-
-	type body struct {
-		AccessToken string `json:"access_token"`
-		ConsumerKey string `json:"consumer_key"`
-		Title       string `json:"title,omitempty"`
-		URL         string `json:"url"`
+		return fmt.Errorf("pocket: missing consumer key or access token")
 	}
 
-	data := &body{
+	apiEndpoint := "https://getpocket.com/v3/add"
+	requestBody, err := json.Marshal(&createItemRequest{
 		AccessToken: c.accessToken,
 		ConsumerKey: c.consumerKey,
-		Title:       title,
-		URL:         link,
+		Title:       entryTitle,
+		URL:         entryURL,
+	})
+	if err != nil {
+		return fmt.Errorf("pocket: unable to encode request body: %v", err)
+	}
+
+	request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
+	if err != nil {
+		return fmt.Errorf("pocket: unable to create request: %v", err)
 	}
 
-	clt := client.New("https://getpocket.com/v3/add")
-	response, err := clt.PostJSON(data)
+	request.Header.Set("Content-Type", "application/json")
+	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
+
+	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	response, err := httpClient.Do(request)
 	if err != nil {
-		return fmt.Errorf("pocket: unable to send url: %v", err)
+		return fmt.Errorf("pocket: unable to send request: %v", err)
 	}
+	defer response.Body.Close()
 
-	if response.HasServerFailure() {
-		return fmt.Errorf("pocket: unable to send url, status=%d", response.StatusCode)
+	if response.StatusCode >= 400 {
+		return fmt.Errorf("pocket: unable to create item: url=%s status=%d", apiEndpoint, response.StatusCode)
 	}
 
 	return nil
 }
+
+type createItemRequest struct {
+	AccessToken string `json:"access_token"`
+	ConsumerKey string `json:"consumer_key"`
+	Title       string `json:"title,omitempty"`
+	URL         string `json:"url"`
+}

+ 30 - 27
internal/integration/readwise/readwise.go

@@ -6,61 +6,64 @@
 package readwise // import "miniflux.app/v2/internal/integration/readwise"
 
 import (
+	"bytes"
+	"encoding/json"
 	"fmt"
-	"net/url"
+	"net/http"
+	"time"
 
-	"miniflux.app/v2/internal/http/client"
+	"miniflux.app/v2/internal/version"
 )
 
-// Document structure of a Readwise Reader document
-// This initial version accepts only the one required field, the URL
-type Document struct {
-	Url string `json:"url"`
-}
+const (
+	readwiseApiEndpoint  = "https://readwise.io/api/v3/save/"
+	defaultClientTimeout = 10 * time.Second
+)
 
-// Client represents a Readwise Reader client.
 type Client struct {
 	apiKey string
 }
 
-// NewClient returns a new Readwise Reader client.
 func NewClient(apiKey string) *Client {
 	return &Client{apiKey: apiKey}
 }
 
-// AddEntry sends an entry to Readwise Reader.
-func (c *Client) AddEntry(link string) error {
+func (c *Client) CreateDocument(entryURL string) error {
 	if c.apiKey == "" {
 		return fmt.Errorf("readwise: missing API key")
 	}
 
-	doc := &Document{
-		Url: link,
+	requestBody, err := json.Marshal(&readwiseDocument{
+		URL: entryURL,
+	})
+
+	if err != nil {
+		return fmt.Errorf("readwise: unable to encode request body: %v", err)
 	}
 
-	apiURL, err := getAPIEndpoint("https://readwise.io/api/v3/save/")
+	request, err := http.NewRequest(http.MethodPost, readwiseApiEndpoint, bytes.NewReader(requestBody))
 	if err != nil {
-		return err
+		return fmt.Errorf("readwise: unable to create request: %v", err)
 	}
 
-	clt := client.New(apiURL)
-	clt.WithAuthorization("Token " + c.apiKey)
-	response, err := clt.PostJSON(doc)
+	request.Header.Set("Content-Type", "application/json")
+	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
+	request.Header.Set("Authorization", "Token "+c.apiKey)
+
+	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	response, err := httpClient.Do(request)
 	if err != nil {
-		return fmt.Errorf("readwise: unable to send entry: %v", err)
+		return fmt.Errorf("readwise: unable to send request: %v", err)
 	}
+	defer response.Body.Close()
 
-	if response.HasServerFailure() {
-		return fmt.Errorf("readwise: unable to send entry, status=%d", response.StatusCode)
+	if response.StatusCode >= 400 {
+		return fmt.Errorf("readwise: unable to create document: url=%s status=%d", readwiseApiEndpoint, response.StatusCode)
 	}
 
 	return nil
 }
 
-func getAPIEndpoint(pathURL string) (string, error) {
-	u, err := url.Parse(pathURL)
-	if err != nil {
-		return "", fmt.Errorf("readwise: invalid API endpoint: %v", err)
-	}
-	return u.String(), nil
+type readwiseDocument struct {
+	URL string `json:"url"`
 }

+ 2 - 2
internal/integration/shaarli/shaarli.go

@@ -29,7 +29,7 @@ func NewClient(baseURL, apiSecret string) *Client {
 	return &Client{baseURL: baseURL, apiSecret: apiSecret}
 }
 
-func (c *Client) AddLink(entryURL, entryTitle string) error {
+func (c *Client) CreateLink(entryURL, entryTitle string) error {
 	if c.baseURL == "" || c.apiSecret == "" {
 		return fmt.Errorf("shaarli: missing base URL or API secret")
 	}
@@ -49,7 +49,7 @@ func (c *Client) AddLink(entryURL, entryTitle string) error {
 		return fmt.Errorf("shaarli: unable to encode request body: %v", err)
 	}
 
-	request, err := http.NewRequest("POST", apiEndpoint, bytes.NewReader(requestBody))
+	request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
 	if err != nil {
 		return fmt.Errorf("shaarli: unable to create request: %v", err)
 	}

+ 3 - 4
internal/integration/shiori/shiori.go

@@ -26,7 +26,7 @@ func NewClient(baseURL, username, password string) *Client {
 	return &Client{baseURL: baseURL, username: username, password: password}
 }
 
-func (c *Client) AddBookmark(entryURL, entryTitle string) error {
+func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
 	if c.baseURL == "" || c.username == "" || c.password == "" {
 		return fmt.Errorf("shiori: missing base URL, username or password")
 	}
@@ -51,13 +51,12 @@ func (c *Client) AddBookmark(entryURL, entryTitle string) error {
 		return fmt.Errorf("shiori: unable to encode request body: %v", err)
 	}
 
-	request, err := http.NewRequest("POST", apiEndpoint, bytes.NewReader(requestBody))
+	request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
 	if err != nil {
 		return fmt.Errorf("shiori: unable to create request: %v", err)
 	}
 
 	request.Header.Set("Content-Type", "application/json")
-	request.Header.Set("Accept", "application/json")
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 	request.Header.Set("X-Session-Id", sessionID)
 
@@ -87,7 +86,7 @@ func (c *Client) authenticate() (sessionID string, err error) {
 		return "", fmt.Errorf("shiori: unable to encode request body: %v", err)
 	}
 
-	request, err := http.NewRequest("POST", apiEndpoint, bytes.NewReader(requestBody))
+	request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
 	if err != nil {
 		return "", fmt.Errorf("shiori: unable to create request: %v", err)
 	}

+ 63 - 39
internal/integration/wallabag/wallabag.go

@@ -4,16 +4,20 @@
 package wallabag // import "miniflux.app/v2/internal/integration/wallabag"
 
 import (
+	"bytes"
 	"encoding/json"
 	"fmt"
-	"io"
+	"net/http"
 	"net/url"
+	"strings"
+	"time"
 
-	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/urllib"
+	"miniflux.app/v2/internal/version"
 )
 
-// Client represents a Wallabag client.
+const defaultClientTimeout = 10 * time.Second
+
 type Client struct {
 	baseURL      string
 	clientID     string
@@ -23,16 +27,13 @@ type Client struct {
 	onlyURL      bool
 }
 
-// NewClient returns a new Wallabag client.
 func NewClient(baseURL, clientID, clientSecret, username, password string, onlyURL bool) *Client {
 	return &Client{baseURL, clientID, clientSecret, username, password, onlyURL}
 }
 
-// AddEntry sends a link to Wallabag.
-// Pass an empty string in `content` to let Wallabag fetch the article content.
-func (c *Client) AddEntry(link, title, content string) error {
+func (c *Client) CreateEntry(entryURL, entryTitle, entryContent string) error {
 	if c.baseURL == "" || c.clientID == "" || c.clientSecret == "" || c.username == "" || c.password == "" {
-		return fmt.Errorf("wallabag: missing credentials")
+		return fmt.Errorf("wallabag: missing base URL, client ID, client secret, username or password")
 	}
 
 	accessToken, err := c.getAccessToken()
@@ -40,29 +41,47 @@ func (c *Client) AddEntry(link, title, content string) error {
 		return err
 	}
 
-	return c.createEntry(accessToken, link, title, content)
+	return c.createEntry(accessToken, entryURL, entryTitle, entryContent)
 }
 
-func (c *Client) createEntry(accessToken, link, title, content string) error {
-	endpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/entries.json")
+func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent string) error {
+	apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/entries.json")
 	if err != nil {
 		return fmt.Errorf("wallbag: unable to generate entries endpoint: %v", err)
 	}
 
-	data := map[string]string{"url": link, "title": title}
-	if !c.onlyURL {
-		data["content"] = content
+	if c.onlyURL {
+		entryContent = ""
+	}
+
+	requestBody, err := json.Marshal(&createEntryRequest{
+		URL:     entryURL,
+		Title:   entryTitle,
+		Content: entryContent,
+	})
+	if err != nil {
+		return fmt.Errorf("wallbag: unable to encode request body: %v", err)
+	}
+
+	request, err := http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBody))
+	if err != nil {
+		return fmt.Errorf("wallbag: unable to create request: %v", err)
 	}
 
-	clt := client.New(endpoint)
-	clt.WithAuthorization("Bearer " + accessToken)
-	response, err := clt.PostJSON(data)
+	request.Header.Set("Content-Type", "application/json")
+	request.Header.Set("Accept", "application/json")
+	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
+	request.Header.Set("Authorization", "Bearer "+accessToken)
+
+	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	response, err := httpClient.Do(request)
 	if err != nil {
-		return fmt.Errorf("wallabag: unable to post entry using %q endpoint: %v", endpoint, err)
+		return fmt.Errorf("wallabag: unable to send request: %v", err)
 	}
+	defer response.Body.Close()
 
-	if response.HasServerFailure() {
-		return fmt.Errorf("wallabag: request failed using %q, status=%d", endpoint, response.StatusCode)
+	if response.StatusCode >= 400 {
+		return fmt.Errorf("wallabag: unable to get access token: url=%s status=%d", apiEndpoint, response.StatusCode)
 	}
 
 	return nil
@@ -76,27 +95,37 @@ func (c *Client) getAccessToken() (string, error) {
 	values.Add("username", c.username)
 	values.Add("password", c.password)
 
-	endpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/oauth/v2/token")
+	apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/oauth/v2/token")
 	if err != nil {
 		return "", fmt.Errorf("wallbag: unable to generate token endpoint: %v", err)
 	}
 
-	clt := client.New(endpoint)
-	response, err := clt.PostForm(values)
+	request, err := http.NewRequest(http.MethodPost, apiEndpoint, strings.NewReader(values.Encode()))
 	if err != nil {
-		return "", fmt.Errorf("wallabag: unable to get access token using %q endpoint: %v", endpoint, err)
+		return "", fmt.Errorf("wallbag: unable to create request: %v", err)
 	}
 
-	if response.HasServerFailure() {
-		return "", fmt.Errorf("wallabag: request failed using %q, status=%d", endpoint, response.StatusCode)
-	}
+	request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	request.Header.Set("Accept", "application/json")
+	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 
-	token, err := decodeTokenResponse(response.Body)
+	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	response, err := httpClient.Do(request)
 	if err != nil {
-		return "", err
+		return "", fmt.Errorf("wallabag: unable to send request: %v", err)
+	}
+	defer response.Body.Close()
+
+	if response.StatusCode >= 400 {
+		return "", fmt.Errorf("wallabag: unable to get access token: url=%s status=%d", apiEndpoint, response.StatusCode)
+	}
+
+	var responseBody tokenResponse
+	if err := json.NewDecoder(response.Body).Decode(&responseBody); err != nil {
+		return "", fmt.Errorf("wallabag: unable to decode token response: %v", err)
 	}
 
-	return token.AccessToken, nil
+	return responseBody.AccessToken, nil
 }
 
 type tokenResponse struct {
@@ -107,13 +136,8 @@ type tokenResponse struct {
 	TokenType    string `json:"token_type"`
 }
 
-func decodeTokenResponse(body io.Reader) (*tokenResponse, error) {
-	var token tokenResponse
-
-	decoder := json.NewDecoder(body)
-	if err := decoder.Decode(&token); err != nil {
-		return nil, fmt.Errorf("wallabag: unable to decode token response: %v", err)
-	}
-
-	return &token, nil
+type createEntryRequest struct {
+	URL     string `json:"url"`
+	Title   string `json:"title"`
+	Content string `json:"content,omitempty"`
 }

+ 1 - 1
internal/locale/translations/de_DE.json

@@ -357,7 +357,7 @@
     "form.integration.notion_token": "Notion Secret Token",
     "form.integration.apprise_activate": "Push entries to Apprise",
     "form.integration.apprise_url": "Apprise API URL",
-    "form.integration.apprise_services_url": "Apprise services urls seperated by comma",
+    "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
     "form.integration.nunux_keeper_activate": "Artikel in Nunux Keeper speichern",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper API-Endpunkt",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper API-Schlüssel",

+ 1 - 1
internal/locale/translations/el_EL.json

@@ -357,7 +357,7 @@
     "form.integration.notion_token": "Notion Secret Token",
     "form.integration.apprise_activate": "Push entries to Apprise",
     "form.integration.apprise_url": "Apprise API URL",
-    "form.integration.apprise_services_url": "Apprise services urls seperated by comma",
+    "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
     "form.integration.nunux_keeper_activate": "Αποθήκευση άρθρων στο Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Τελικό σημείο Nunux Keeper API",
     "form.integration.nunux_keeper_api_key": "Κλειδί API Nunux Keeper",

+ 1 - 1
internal/locale/translations/en_US.json

@@ -357,7 +357,7 @@
     "form.integration.notion_token": "Notion Secret Token",
     "form.integration.apprise_activate": "Push entries to Apprise",
     "form.integration.apprise_url": "Apprise API URL",
-    "form.integration.apprise_services_url": "Apprise Services URLs (seperated by comma)",
+    "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
     "form.integration.nunux_keeper_activate": "Save entries to Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper API Endpoint",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper API key",

+ 1 - 1
internal/locale/translations/es_ES.json

@@ -357,7 +357,7 @@
     "form.integration.notion_token": "Notion Secret Token",
     "form.integration.apprise_activate": "Push entries to Apprise",
     "form.integration.apprise_url": "Apprise API URL",
-    "form.integration.apprise_services_url": "Apprise services urls seperated by comma",
+    "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
     "form.integration.nunux_keeper_activate": "Enviar artículos a Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Acceso API de Nunux Keeper",
     "form.integration.nunux_keeper_api_key": "Clave de API de Nunux Keeper",

+ 1 - 1
internal/locale/translations/fi_FI.json

@@ -357,7 +357,7 @@
     "form.integration.notion_token": "Notion Secret Token",
     "form.integration.apprise_activate": "Push entries to Apprise",
     "form.integration.apprise_url": "Apprise API URL",
-    "form.integration.apprise_services_url": "Apprise services urls seperated by comma",
+    "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
     "form.integration.nunux_keeper_activate": "Tallenna artikkelit Nunux Keeperiin",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper API-päätepiste",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper API-avain",

+ 3 - 3
internal/locale/translations/fr_FR.json

@@ -353,11 +353,11 @@
     "form.integration.wallabag_username": "Nom d'utilisateur de Wallabag",
     "form.integration.wallabag_password": "Mot de passe de Wallabag",
     "form.integration.notion_activate": "Sauvegarder les articles vers Notion",
-    "form.integration.notion_page_id": "l'identifiant de la page Notion",
+    "form.integration.notion_page_id": "Identifiant de la page Notion",
     "form.integration.notion_token": "Jeton d'accès de l'API de Notion",
-    "form.integration.apprise_activate": "Push entries to Apprise",
+    "form.integration.apprise_activate": "Emvoyer les articles vers Apprise",
     "form.integration.apprise_url": "Apprise API URL",
-    "form.integration.apprise_services_url": "Apprise services urls seperated by comma",
+    "form.integration.apprise_services_url": "Comma separated list of Apprise services",
     "form.integration.nunux_keeper_activate": "Sauvegarder les articles vers Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "URL de l'API de Nunux Keeper",
     "form.integration.nunux_keeper_api_key": "Clé d'API de Nunux Keeper",

+ 1 - 1
internal/locale/translations/hi_IN.json

@@ -357,7 +357,7 @@
     "form.integration.notion_token": "Notion Secret Token",
     "form.integration.apprise_activate": "Push entries to Apprise",
     "form.integration.apprise_url": "Apprise API URL",
-    "form.integration.apprise_services_url": "Apprise services urls seperated by comma",
+    "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
     "form.integration.nunux_keeper_activate": "विषय-वस्तु को ननक्स कीपर में सहेजें",
     "form.integration.nunux_keeper_endpoint": "ननक्स कीपर एपीआई समापन बिंदु",
     "form.integration.nunux_keeper_api_key": "ननक्स कीपर एपीआई कुंजी",

+ 1 - 1
internal/locale/translations/id_ID.json

@@ -354,7 +354,7 @@
     "form.integration.notion_token": "Notion Secret Token",
     "form.integration.apprise_activate": "Push entries to Apprise",
     "form.integration.apprise_url": "Apprise API URL",
-    "form.integration.apprise_services_url": "Apprise services urls seperated by comma",
+    "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
     "form.integration.nunux_keeper_activate": "Simpan artikel ke Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Titik URL API Nunux Keeper",
     "form.integration.nunux_keeper_api_key": "Kunci API Nunux Keeper",

+ 1 - 1
internal/locale/translations/it_IT.json

@@ -357,7 +357,7 @@
     "form.integration.notion_token": "Notion Secret Token",
     "form.integration.apprise_activate": "Push entries to Apprise",
     "form.integration.apprise_url": "Apprise API URL",
-    "form.integration.apprise_services_url": "Apprise services urls seperated by comma",
+    "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
     "form.integration.nunux_keeper_activate": "Salva gli articoli su Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Endpoint dell'API di Nunux Keeper",
     "form.integration.nunux_keeper_api_key": "API key dell'account Nunux Keeper",

+ 1 - 1
internal/locale/translations/ja_JP.json

@@ -357,7 +357,7 @@
     "form.integration.notion_token": "Notion Secret Token",
     "form.integration.apprise_activate": "Push entries to Apprise",
     "form.integration.apprise_url": "Apprise API URL",
-    "form.integration.apprise_services_url": "Apprise services urls seperated by comma",
+    "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
     "form.integration.nunux_keeper_activate": "Nunux Keeper に記事を保存する",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper の API Endpoint",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper の API key",

+ 1 - 1
internal/locale/translations/nl_NL.json

@@ -357,7 +357,7 @@
     "form.integration.notion_token": "Notion Secret Token",
     "form.integration.apprise_activate": "Push entries to Apprise",
     "form.integration.apprise_url": "Apprise API URL",
-    "form.integration.apprise_services_url": "Apprise services urls seperated by comma",
+    "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
     "form.integration.nunux_keeper_activate": "Opslaan naar Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper API-sleutel",

+ 1 - 1
internal/locale/translations/pl_PL.json

@@ -359,7 +359,7 @@
     "form.integration.notion_token": "Notion Secret Token",
     "form.integration.apprise_activate": "Push entries to Apprise",
     "form.integration.apprise_url": "Apprise API URL",
-    "form.integration.apprise_services_url": "Apprise services urls seperated by comma",
+    "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
     "form.integration.nunux_keeper_activate": "Zapisz artykuly do Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper API key",

+ 1 - 1
internal/locale/translations/pt_BR.json

@@ -357,7 +357,7 @@
     "form.integration.notion_token": "Notion Secret Token",
     "form.integration.apprise_activate": "Push entries to Apprise",
     "form.integration.apprise_url": "Apprise API URL",
-    "form.integration.apprise_services_url": "Apprise services urls seperated by comma",
+    "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
     "form.integration.nunux_keeper_activate": "Salvar itens no Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Endpoint de API do Nunux Keeper",
     "form.integration.nunux_keeper_api_key": "Chave de API do Nunux Keeper",

+ 1 - 1
internal/locale/translations/ru_RU.json

@@ -359,7 +359,7 @@
     "form.integration.notion_token": "Notion Secret Token",
     "form.integration.apprise_activate": "Push entries to Apprise",
     "form.integration.apprise_url": "Apprise API URL",
-    "form.integration.apprise_services_url": "Apprise services urls seperated by comma",
+    "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
     "form.integration.nunux_keeper_activate": "Сохранять статьи в Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Конечная точка Nunux Keeper API",
     "form.integration.nunux_keeper_api_key": "API-ключ Nunux Keeper",

+ 1 - 1
internal/locale/translations/tr_TR.json

@@ -357,7 +357,7 @@
     "form.integration.notion_token": "Notion Secret Token",
     "form.integration.apprise_activate": "Push entries to Apprise",
     "form.integration.apprise_url": "Apprise API URL",
-    "form.integration.apprise_services_url": "Apprise services urls seperated by comma",
+    "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
     "form.integration.nunux_keeper_activate": "Makaleleri Nunux Keeper'a kaydet",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper API Uç Noktası",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper API anahtarı",

+ 1 - 1
internal/locale/translations/uk_UA.json

@@ -360,7 +360,7 @@
   "form.integration.notion_token": "Notion Secret Token",
   "form.integration.apprise_activate": "Push entries to Apprise",
   "form.integration.apprise_url": "Apprise API URL",
-  "form.integration.apprise_services_url": "Apprise services urls seperated by comma",
+  "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
   "form.integration.nunux_keeper_activate": "Зберігати статті до Nunux Keeper",
   "form.integration.nunux_keeper_endpoint": "Nunux Keeper API Endpoint",
   "form.integration.nunux_keeper_api_key": "Ключ API Nunux Keeper",

+ 1 - 1
internal/locale/translations/zh_CN.json

@@ -355,7 +355,7 @@
     "form.integration.notion_token": "Notion Secret Token",
     "form.integration.apprise_activate": "Push entries to Apprise",
     "form.integration.apprise_url": "Apprise API URL",
-    "form.integration.apprise_services_url": "Apprise services urls seperated by comma",
+    "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
     "form.integration.nunux_keeper_activate": "保存文章到 Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper API 端点",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper API 密钥",

+ 1 - 1
internal/locale/translations/zh_TW.json

@@ -357,7 +357,7 @@
     "form.integration.notion_token": "Notion Secret Token",
     "form.integration.apprise_activate": "Push entries to Apprise",
     "form.integration.apprise_url": "Apprise API URL",
-    "form.integration.apprise_services_url": "Apprise services urls seperated by comma",
+    "form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
     "form.integration.nunux_keeper_activate": "儲存文章到 Nunux Keeper",
     "form.integration.nunux_keeper_endpoint": "Nunux Keeper API 端點",
     "form.integration.nunux_keeper_api_key": "Nunux Keeper API 金鑰",