paperless

package module
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 13, 2026 License: MIT Imports: 13 Imported by: 0

README

go-paperless

CI Go Reference Go

Paperless-ngx REST client SDK for Go.

One client, two consumers: InboxClean and bank-sync share this client instead of maintaining divergent forks. Scope is deliberately client only — document sync pipelines are domain-coupled and stay in the consuming repos.

Features

  • Upload documents (content-hash friendly metadata: tags, correspondents, document types, custom fields)
  • Ensure* idempotent lookups: EnsureTag, EnsureCorrespondent, EnsureDocumentType, EnsureCustomField (tags self-heal legacy auto-matching)
  • Task polling (GetTask, TaskOutcome) for Paperless' async consumption pipeline, including duplicate-refusal detection
  • Document management: list checksums/metadata, update metadata, download, delete
  • Name resolution: GetCorrespondentName, GetDocumentTypeName
  • Capability probing (ProbeCapabilities) for version differences
  • Respectful retry: RetryAfterError carries Retry-After hints
  • Typed errors via go-error-family

Installation

go get github.com/larsartmann/go-paperless

Requirements

Go 1.27+. The module uses encoding/json/v2, which is the default there — no GOEXPERIMENT is needed. (On a Go ≤ 1.26 toolchain json/v2 only exists behind GOEXPERIMENT=jsonv2, and builds fail with "build constraints exclude all Go files" without it.)

Getting started

package main

import (
	"context"
	"fmt"

	"github.com/larsartmann/go-paperless"
)

func main() {
	client, err := paperless.New("http://paperless.local:8000", "my-token")
	if err != nil {
		panic(err)
	}
	ctx := context.Background()
	if err := client.Ping(ctx); err != nil {
		panic(err)
	}
	tagID, err := client.EnsureTag(ctx, "inboxclean")
	if err != nil {
		panic(err)
	}
	fmt.Println(tagID)
}

Options

Option Effect
WithHTTPClient(*http.Client) Use a custom HTTP client (transport, proxies, timeouts)
WithTimeout(time.Duration) Per-request timeout on the default client

Development

nix develop          # dev shell (Go 1.27, GOEXPERIMENT=jsonv2,simd)
nix run .#check      # all checks (build, test, lint, format)
nix run .#build      # build
nix run .#test       # tests
nix run .#test-race  # tests with race detector
nix run .#lint       # golangci-lint
nix fmt              # format
nix flake check      # all checks (CI equivalent)

License

MIT — see LICENSE.

For security reporting, see SECURITY.md; to contribute, see CONTRIBUTING.md.

Documentation

Overview

Package paperless provides a Go client for the Paperless-ngx REST API: document upload, tag/correspondent/type/custom-field management, task polling, and document management.

It targets API version 10 and authenticates via the static token scheme (`Authorization: Token <token>`), which users generate in the Paperless-ngx web UI under "My Profile".

Requirements

Go 1.27 or newer. The module decodes with `encoding/json/v2`, which is part of the standard library there. On a Go 1.26 or older toolchain it only exists behind `GOEXPERIMENT=jsonv2` and builds fail without it.

Options

WithHTTPClient(*http.Client) — use a custom HTTP client (transport, proxies, timeouts)
WithTimeout(time.Duration)   — per-request timeout on the default client

Index

Examples

Constants

View Source
const (
	// DefaultMaxIdleConns is the total idle connection pool size.
	DefaultMaxIdleConns = 100
	// DefaultMaxIdleConnsPerHost is the per-host idle pool — the value that
	// actually matters when all traffic targets one API host.
	DefaultMaxIdleConnsPerHost = 8
	// DefaultIdleConnTimeout matches the default transport's 90s so pooled
	// connections do not outlive typical server keep-alive windows.
	DefaultIdleConnTimeout = 90 * time.Second
)

Default transport tuning shared by every consumer of this SDK. The default transport keeps only 2 idle connections per host, so parallel requests to the single Paperless-ngx host would close and re-handshake TLS connections constantly; a cloned transport with a per-host idle pool turns that into connection reuse.

Variables

View Source
var ErrInvalidConfig = errors.New("paperless: base URL and token are required")

ErrInvalidConfig is returned when the client is constructed with a missing base URL or token. The integration is optional; callers that have not configured Paperless should never construct a client.

Functions

This section is empty.

Types

type Capabilities

type Capabilities struct {
	// AcceptAPIVersion echoes the API version the server negotiated from
	// the Accept header (empty when the response omits it).
	AcceptAPIVersion string
	// FlatChecksum: sampled documents carry the legacy flat checksum field.
	FlatChecksum bool
	// VersionedChecksum: sampled documents carry checksums in versions[].
	VersionedChecksum bool
	// DocumentsSampled is how many documents the probe inspected.
	DocumentsSampled int
}

Capabilities reports what the connected Paperless-ngx server actually serves. The checksum shape is the load-bearing part: --verify, --prune, and --backfill all reconcile by checksum, and since paperless-ngx 3.x the flat checksum field is gone from the serializers (served via versions[] instead) — a server serving NEITHER shape makes every ledger row look drifted. Doctor surfaces this before it bites.

func (Capabilities) ChecksumShape

func (c Capabilities) ChecksumShape() string

ChecksumShape names the checksum delivery the server uses, for humans.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client talks to a single Paperless-ngx instance. The zero value is not usable — construct via New.

func New

func New(baseURL, token string, opts ...Option) (*Client, error)

New creates a client for the given base URL (e.g. "https://paperless.example.com") and API token. Returns ErrInvalidConfig when either is empty or the URL is not parseable. Options customize the HTTP transport.

Example
package main

import (
	"log"

	"github.com/larsartmann/go-paperless"
)

func main() {
	client, err := paperless.New("https://paperless.example.com", "token-from-web-ui")
	if err != nil {
		log.Fatal(err)
	}
	_ = client
}
Example (InvalidConfig)
package main

import (
	"errors"
	"fmt"

	"github.com/larsartmann/go-paperless"
)

func main() {
	_, err := paperless.New("", "token-from-web-ui")
	if errors.Is(err, paperless.ErrInvalidConfig) {
		fmt.Println("configure the base URL before retrying")
	}
}
Output:
configure the base URL before retrying
Example (WithOptions)
package main

import (
	"log"
	"time"

	"github.com/larsartmann/go-paperless"
)

func main() {
	client, err := paperless.New(
		"https://paperless.example.com",
		"token-from-web-ui",
		paperless.WithTimeout(30*time.Second),
	)
	if err != nil {
		log.Fatal(err)
	}
	_ = client
}

func (*Client) DeleteDocument

func (c *Client) DeleteDocument(ctx context.Context, documentID int) error

DeleteDocument permanently removes one document from Paperless-ngx. Used by the backfill's --prune to drop duplicate server-side copies; callers own the destructive-action decision.

func (*Client) DownloadDocument

func (c *Client) DownloadDocument(ctx context.Context, documentID int) ([]byte, error)

DownloadDocument fetches one document's stored ORIGINAL file bytes (GET /api/documents/{id}/download/ — not the archive rendition). The decrypt-repair scans them for the /Encrypt trailer before replacing.

func (*Client) EnsureCorrespondent

func (c *Client) EnsureCorrespondent(ctx context.Context, name string) (int, error)

EnsureCorrespondent returns the ID of the correspondent with the given name, creating it when it does not exist yet.

Correspondents keep the "auto" matching algorithm (deliberately, unlike tags): learning "documents from sender X look like this" is exactly the value auto matching adds for documents NOT uploaded by this pipeline (manual scans), and correspondents carry no provenance semantics that auto prediction could corrupt.

func (*Client) EnsureCustomField

func (c *Client) EnsureCustomField(ctx context.Context, name string) (int, error)

EnsureCustomField returns the ID of the custom field definition with the given name, creating it as a string field when missing (paperless-ngx 2.x+ custom fields). Used for the provenance field carrying the Gmail message ID. An existing definition of any data type is kept — the caller decides whether a non-string field is acceptable by the value it assigns.

func (*Client) EnsureDocumentType

func (c *Client) EnsureDocumentType(ctx context.Context, name string) (int, error)

EnsureDocumentType returns the ID of the document type with the given name, creating it with the "none" matching algorithm when missing. A document type applied deterministically at upload time is provenance metadata: classifier-learned matching would leak the type onto manual scans, mirroring the tags rationale. An EXISTING type keeps its configured algorithm — unlike tags there is no legacy-release shape to heal, and overriding a deliberate user config would be wrong.

func (*Client) EnsureTag

func (c *Client) EnsureTag(ctx context.Context, name string) (int, error)

EnsureTag returns the ID of the tag with the given name, creating it when it does not exist yet.

Tags are provenance metadata applied deterministically at upload time, so they are created with the "none" matching algorithm: a "gmail" tag that Paperless-ngx's classifier learns to predict would silently leak onto manually scanned documents that merely look similar. An existing tag still carrying "auto" (created by older InboxClean versions) is self-healed to "none" — the classifier then no longer trains against it. Self-healing is tag-specific on purpose: correspondents deliberately keep "auto".

Example

ExampleClient_EnsureTag mirrors the README quick start so signature drift between README and code breaks the build.

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/larsartmann/go-paperless"
)

func main() {
	client, err := paperless.New("http://paperless.local:8000", "my-token")
	if err != nil {
		log.Fatal(err)
	}

	ctx := context.Background()

	tagID, err := client.EnsureTag(ctx, "inboxclean")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(tagID)
}

func (*Client) FindCustomField

func (c *Client) FindCustomField(ctx context.Context, name string) (int, bool, error)

FindCustomField looks up a custom field definition by name WITHOUT creating it — the read-only half of EnsureCustomField, used by dry-run backfills to report what they would record without mutating the server.

func (*Client) GetCorrespondentName

func (c *Client) GetCorrespondentName(ctx context.Context, id int) (string, error)

GetCorrespondentName resolves a correspondent ID to its display name. The backfill needs it to decide whether a document's existing non-null correspondent actually matches the derived sender name — an ID alone cannot answer that question.

func (*Client) GetDocumentTypeName

func (c *Client) GetDocumentTypeName(ctx context.Context, id int) (string, error)

GetDocumentTypeName resolves a document type ID to its display name, the document-type sibling of GetCorrespondentName: the backfill compares the stored name against the configured type to repair drifted assignments.

func (*Client) GetTask

func (c *Client) GetTask(ctx context.Context, taskID string) (TaskOutcome, bool, error)

GetTask fetches one consumption task by ID (the UUID Upload returns). found is false when the server has no such task — the record may not be persisted yet (polling callers retry) or was pruned.

The response is expected in the v10 paginated envelope (`{"results": [...]}`); a bare JSON array is tolerated defensively for deployments that predate pagination.

Example
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/larsartmann/go-paperless"
)

func main() {
	client, err := paperless.New("https://paperless.example.com", "token-from-web-ui")
	if err != nil {
		log.Fatal(err)
	}

	ctx := context.Background()

	outcome, found, err := client.GetTask(ctx, "0198f7a2-9d3f-7c31-b5e4-5f2a9c1d8e77")
	if err != nil {
		log.Fatal(err)
	}
	if found {
		fmt.Println(outcome.Status, outcome.DocumentID)
	}
}

func (*Client) ListDocumentChecksums

func (c *Client) ListDocumentChecksums(ctx context.Context) (map[string]struct{}, error)

ListDocumentChecksums returns the SHA-256 checksum of every document currently stored in Paperless-ngx. Used to reconcile the local upload ledger against reality: ledger entries whose checksum disappeared from Paperless-ngx (documents deleted there) are candidates for re-upload.

func (*Client) ListDocumentMetas

func (c *Client) ListDocumentMetas(ctx context.Context) ([]DocumentMeta, error)

ListDocumentMetas returns id, title, correspondent, created date, tags, and checksum of every stored document. The backfill matches ledger records against server documents (by checksum) and detects duplicates (same checksum stored more than once).

func (*Client) Ping

func (c *Client) Ping(ctx context.Context) error

Ping verifies base URL and token by hitting the authenticated document list view (a real JSON endpoint, bounded to one entry). The API root is deliberately avoided: Paperless-ngx serves it as browsable HTML only, so a JSON Accept header is answered 406 regardless of token validity (observed on paperless 3.0.5 — 406 classified as a client rejection, silently skipping every sync). A 401/403 means the token is wrong; a transport error means the URL is unreachable.

func (*Client) ProbeCapabilities

func (c *Client) ProbeCapabilities(ctx context.Context) (Capabilities, error)

ProbeCapabilities fetches one small documents page and inspects the checksum shapes the server serves. Read-only, one request, safe to run against any paperless-ngx version.

func (*Client) UpdateDocument

func (c *Client) UpdateDocument(
	ctx context.Context,
	documentID int,
	req UpdateDocumentRequest,
) error

UpdateDocument PATCHes one document's metadata. Empty requests are rejected so callers never burn a round trip on a no-op.

func (*Client) Upload

func (c *Client) Upload(ctx context.Context, req UploadRequest) (string, error)

Upload posts a document to the consumption queue. It returns the task ID (UUID) Paperless-ngx assigned; consumption itself is asynchronous — the caller can poll `/api/tasks/?task_id=<id>` for the resulting document.

Example
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/larsartmann/go-paperless"
)

func main() {
	client, err := paperless.New("https://paperless.example.com", "token-from-web-ui")
	if err != nil {
		log.Fatal(err)
	}

	ctx := context.Background()

	tagID, err := client.EnsureTag(ctx, "bank-sync")
	if err != nil {
		log.Fatal(err)
	}

	taskID, err := client.Upload(ctx, paperless.UploadRequest{
		Filename: "statement-2026-08.pdf",
		Content:  []byte("%PDF-1.4 ..."),
		Title:    "Statement August 2026",
		TagIDs:   []int{tagID},
	})
	if err != nil {
		log.Fatal(err)
	}

	outcome, found, err := client.GetTask(ctx, taskID)
	if err != nil {
		log.Fatal(err)
	}
	if found && outcome.Status.Terminal() {
		fmt.Println("consumed:", outcome.Status)
	}
}

type CustomFieldValue

type CustomFieldValue struct {
	Field int
	Value string
}

CustomFieldValue assigns a value to one custom field by its definition ID.

type DocumentMeta

type DocumentMeta struct {
	ID             int
	Title          string
	Correspondent  int
	Created        time.Time
	TagIDs         []int
	DocumentTypeID int
	CustomFields   []CustomFieldValue
	Checksum       string
}

DocumentMeta is the metadata snapshot of one stored Paperless-ngx document.

type Option

type Option func(*Client)

Option configures a Client at construction time.

func WithHTTPClient

func WithHTTPClient(client *http.Client) Option

WithHTTPClient uses the given HTTP client instead of the default one. When set, WithTimeout has no effect (the supplied client owns its own timeout).

func WithTimeout

func WithTimeout(timeout time.Duration) Option

WithTimeout sets the per-request timeout of the client's HTTP client.

type RetryAfterError

type RetryAfterError struct {
	Err error
	// After is the parsed hint. Seconds-only or HTTP-date forms are
	// accepted; a date in the past parses as 0 (retry immediately).
	After time.Duration
}

RetryAfterError reports a rate-limit (or maintenance) response that carried a Retry-After header. It wraps the classified family error so retry policies can honor the server's hint instead of blind exponential backoff, while IsRetryable keeps working through Unwrap.

func (*RetryAfterError) Error

func (e *RetryAfterError) Error() string

func (*RetryAfterError) Unwrap

func (e *RetryAfterError) Unwrap() error

type TaskOutcome

type TaskOutcome struct {
	Status TaskStatus
	// DocumentID is the server document this task points at: the newly
	// created document after a successful consumption, or the pre-existing
	// duplicate when the server refused the upload. Zero when unknown.
	DocumentID int64
	// DuplicateRefused is true when the consumer rejected the upload because
	// an identical document already exists (checksum dedup). The ledger uses
	// this to separate honest refusals from real failures.
	DuplicateRefused bool
	// DuplicateInTrash refines DuplicateRefused: the duplicate document is
	// in the trash (server treats trash duplicates as re-consumable).
	DuplicateInTrash bool
	// ErrorMessage is the server's failure reason for non-duplicate failures.
	ErrorMessage string
}

TaskOutcome is the classified result of one consumption-task poll.

type TaskStatus

type TaskStatus string

TaskStatus is a Paperless-ngx consumption task's lifecycle state. The v10 API serves lowercase values; terminal states are success and failure (verified against paperless-ngx 3.x PaperlessTask.Status).

const (
	TaskStatusPending TaskStatus = "pending"
	TaskStatusStarted TaskStatus = "started"
	TaskStatusSuccess TaskStatus = "success"
	TaskStatusFailure TaskStatus = "failure"
)

Task consumption statuses served by the v10 API (lowercase values).

func (TaskStatus) Terminal

func (s TaskStatus) Terminal() bool

Terminal reports whether the status is a final task state. Unknown status strings (future server versions) poll forever — the safe default for a caller deciding whether to retry.

type UpdateDocumentRequest

type UpdateDocumentRequest struct {
	Title           *string
	Created         *time.Time
	CorrespondentID *int
	TagIDs          []int
	DocumentTypeID  *int
	CustomFields    []CustomFieldValue
}

UpdateDocumentRequest carries the fields a metadata backfill may change. Nil pointers leave the field untouched; TagIDs replaces the full tag set (Paperless-ngx PATCH semantics for many-to-many fields).

type UploadRequest

type UploadRequest struct {
	// Filename is the original file name (e.g. "invoice.pdf"). Paperless-ngx
	// derives the stored document name and archive serial numbering from it.
	Filename string
	// Content holds the raw file bytes. Gmail attachments are capped at 25 MB,
	// so buffering in memory is safe.
	Content []byte
	// Title overrides the consumer-assigned title when set.
	Title string
	// Created sets the document's creation date (typically the email date).
	Created time.Time
	// CorrespondentID is the Paperless-ngx correspondent (e.g. the email
	// sender) assigned on consumption. Zero leaves the field unset.
	CorrespondentID int
	// TagIDs are Paperless-ngx tag IDs applied on consumption.
	TagIDs []int
	// DocumentTypeID is the Paperless-ngx document type (e.g. "Email")
	// applied on consumption. Zero leaves the type unset.
	DocumentTypeID int
	// CustomFields are custom-field values applied on consumption, e.g. the
	// provenance field carrying the Gmail message ID. Nil leaves them unset.
	CustomFields []CustomFieldValue
}

UploadRequest describes one document to hand to Paperless-ngx's consumer.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL