goeland

August 8, 2026 · View on GitHub

goeland

GitHub release (latest by date) version GitHub Image license Build Status Docker images CodeQL

Turn any RSS/Atom feed into a beautiful email digest — self-hosted, no cloud required.

Support this project by giving it a ⭐️ and sharing it.

Features

  • Beautiful HTML emails — responsive template for mobile, tablet, desktop, and webmail clients
  • Full-text extraction — fetch and embed complete article content, not just summaries
  • 20+ composable filtersunseen, today, retrieve, digest, language, reskip, and more
  • Built-in scheduler — per-pipe cron scheduling in daemon mode, no external cron needed
  • Multiple source types — RSS/Atom/JSON feeds, Imgur tags, Miniflux instances, and merged/combined sources
  • Docker-native — single container, one config file, ready to self-host
  • Zero cloud dependency — your feeds, your server, your inbox

Contents

About

Goeland excels at creating beautiful emails from RSS feeds, tailored for daily or weekly digests. It includes a rich set of composable filters that transform feed content along the way, and can also consume other sources such as Imgur tags.

Goeland transforms this...

<rss version="2.0">
<channel>
<title>Phoronix</title>
<link>https://www.phoronix.com/</link>
<description>
Linux Hardware Reviews, Benchmarks & Open-Source News
</description>
<language>en-us</language>
<item>
<title>
Google Announces KataOS As Security-Focused OS, Leveraging Rust & seL4 Microkernel
</title>
<link>https://www.phoronix.com/news/Google-KataOS</link>
<guid>https://www.phoronix.com/news/Google-KataOS</guid>
<description>
Google this week has announced the release of KataOS as their newest operating system effort focused on embedded devices running ambient machine learning workloads. KataOS is security-minded, exclusively uses the Rust programming language, and is built atop the seL4 microkernel as its foundation...
</description>
<pubDate>Sun, 16 Oct 2022 06:10:25 -0400</pubDate>
</item>
</rss>

into this

email

Status

Goeland is used in production with many email clients, and has sent over thousands of emails. It is considered stable.

Installation

Grab the latest binary from the release page. Binaries are available for the following platforms:

  • linux/386
  • linux/amd64
  • linux/arm
  • linux/arm64
  • darwin/amd64
  • windows/amd64
  • windows/386

Just put it in a folder where you have write permissions and run it first with :

goeland run

If you need support for another platform, please open a PR or submit a feature request.

Docker

Images are published to both Docker Hub and GHCR for linux/amd64, arm64, arm/v6, and arm/v7:

docker run -v ./config.toml:/data/config.toml slurdge/goeland
# or
docker run -v ./config.toml:/data/config.toml ghcr.io/slurdge/goeland

The default command is daemon — the container runs continuously and dispatches pipes on their configured cron schedules. Mount the database file to persist the unseen filter state across restarts:

# docker-compose.yml
services:
  goeland:
    image: ghcr.io/slurdge/goeland
    volumes:
      - ./config.toml:/data/config.toml
      - ./goeland.db:/data/goeland.db
    restart: unless-stopped

Usage

On first run, goeland creates a config.toml with default values if one does not exist. Adjust the [email] section with your SMTP details. All config values can also be set via environment variables (e.g. GOELAND_EMAIL_PASSWORD_FILE=/path/to/pass).

Sources

Define sources in the [sources] section. Each source is identified by its key name:

[sources.hackernews]
type = "feed"
url = "https://hnrss.org/newest"
filters = ["all", "today"]

You can then use 'hackernews' in the following pipes.

The different source types are:

  • "feed": RSS, Atom or JSON feed (all supported formats can be found here). Fill in the url field.
  • "imgur": Return most recent results for a tag. Fill in the tag field.
  • "miniflux": Fetch entries from a Miniflux instance through its REST API. Fill in the url field with an API URL — see Miniflux below.
  • "merge": Will merge two or more sources together. Fill in the sources field with a list of sources: sources = ["source1", "source2"]. Especially useful to merge different sources on the same topic. Don't forget to digest or combine it later.

Miniflux

The miniflux source type reads entries straight from a (usually self-hosted) Miniflux instance using its REST API. Any feed, category, search, or starred list you already curate in Miniflux can become an email digest.

First create an API key in Miniflux under Settings → API Keys and add it at the top level of your goeland config (or set the GOELAND_MINIFLUX_API_TOKEN environment variable):

miniflux-api-token = "your-api-key"

If you use several Miniflux instances, set api-token inside a source block to override the global token for that source.

The source url is a Miniflux API URL, not a web UI URL. Translating from what you see in your browser is mechanical: insert /v1, pluralize feed/category, and express everything else as query parameters.

What you wantWeb UI URL looks likeAPI url to use in goeland
A single feed.../feed/275/entrieshttps://miniflux.example.org/v1/feeds/275/entries
A whole category.../category/22/entrieshttps://miniflux.example.org/v1/categories/22/entries
A search.../search?q=solar+powerhttps://miniflux.example.org/v1/entries?search=solar+power
Unread entries.../unreadhttps://miniflux.example.org/v1/entries?status=unread
Starred entries.../starredhttps://miniflux.example.org/v1/entries?starred=true

All three entry endpoints (/v1/entries, /v1/feeds/{id}/entries, /v1/categories/{id}/entries) accept the same query parameters, combined with &:

  • status=unread — only unread entries (also read, removed; repeat the parameter for several statuses)
  • limit=50 — recommended, otherwise the server may return every matching entry
  • order=published_at&direction=desc — newest first
  • search=... — full-text search, URL-encoded: spaces become +, quotes %22. Supports %22exact phrases%22, OR, and -term exclusion
  • starred=true, category_id=22, published_after=<unix timestamp>, and more — see the API reference

For example, a search across all your feeds:

[sources.puppies]
type = "miniflux"
url = "https://miniflux.example.org/v1/entries?search=cute+puppy&order=published_at&direction=desc"
filters = ["unseen", "includelink", "embedimage", "digest"]

Two things to keep in mind:

  • By default goeland only reads from Miniflux — entries are never marked as read, so a status=unread query returns the same entries on every run. Add the unseen filter to deduplicate between runs, or set mark-as-read = true (see below) to have goeland mark fetched entries as read in Miniflux itself.
  • If your instance uses a self-signed certificate, set allow-insecure = true on the source.

Set mark-as-read = true on a source to have goeland mark every entry it just fetched as read on the Miniflux instance, right after fetching:

[sources.puppies]
type = "miniflux"
url = "https://miniflux.example.org/v1/entries?status=unread&search=cute+puppy&order=published_at&direction=desc"
mark-as-read = true
filters = ["includelink", "embedimage", "digest"]

A complete configuration is available in examples/miniflux.toml.

Filtering

Filters are the heart of goeland. They are composable and order matters — applied left to right.

filters = ["unseen", "retrieve", "digest"]

This keeps only previously unseen entries, fetches their full content, then combines them into a single digest email.

Filter reference

FilterDescriptionArgs
allInclude all entries (default)
noneRemove all entries
firstKeep the first N entriesN (default 1)
lastKeep the last N entriesN (default 1)
reverseReverse the order of entries
randomKeep N random entriesN (default 1)
unseenKeep only entries not previously seen (tracked in goeland.db)
todayKeep only entries published today
lasthoursKeep only entries from the last N hoursN (default 24)
digestCombine all entries into a single digest emailheading level (default 2)
combineLike digest, but uses the first entry's title as the subjectheading level (default 2)
linksFix protocol-relative links (//) to https://
embedimageEmbed image from entry attachmenttop, bottom, left, or right (default top)
replaceReplace a string using a named config blockconfig key
includelinkMake entry titles into links in digest form
includesourcetitleShow source title per entry in digest form
retrieveFetch full article content using a CSS selectorCSS selector
languageKeep only entries in specified languages (best-effort detection)ISO 639-1 codes, e.g. en,de
untrackRemove FeedBurner tracking pixels
redditBetter formatting for Reddit RSS feeds
sanitizeSanitize HTML (use after --unsafe-no-sanitize-filter)
tocPrepend a table of contents entrytitle (optional, links TOC title to source)
limitwordsTruncate entry content to N wordsN
reskipSkip entries whose titles match a regular expressionregex

Full documentation with examples: filters.md

The replace filter requires a companion config block:

filters = ["replace(myreplace)"]

[replace.myreplace]
from = "A string"
to = "Another string"

Pipes

A pipe connects a source to a destination. One source can feed multiple pipes, but each pipe has exactly one source. Use the merge source type to combine multiple feeds.

[pipes.hackernews]
disabled = false
source = "hackernews"
destination = "email"
email_from = "HackerNews <goeland@olympus.com>"
email_replyto = "hera@olympus.com"
email_to = ["zeus@olympus.com", "athena@olympus.com"]
email_cc = ["apollo@olympus.com"]
email_bcc = ["hades@olympus.com"]
#Default: you can use EntryTitle, SourceTitle and SourceName in the template
#email_title = "{{.EntryTitle}}"  # optional
#template = "/path/to/template.html" # optional

Set destination = "terminal" for debugging or to pipe output to another system.

To disable a pipe without removing it, set disabled = true or rename the section to [disabled.pipes.hackernews].

HTML file output

Set destination = "htmlfile" to write each entry as a standalone HTML file that other tools can pick up. The output directory and filename are configurable, either globally in a [htmlfile] section or per pipe:

[htmlfile]
path = "data"                                    # default output directory
filename = "{{.Pipe}} - {{.EntryNumber}}.html"   # default filename template

[pipes.hackernews]
source = "hackernews"
destination = "htmlfile"
htmlfile_path = "/var/www/feeds"                          # optional, overrides htmlfile.path
htmlfile_filename = "{{.SourceName}}-{{.EntryUID}}.html"  # optional, overrides htmlfile.filename

The filename is a Go template.

Scheduling

In daemon mode (goeland daemon), each pipe runs on its own cron schedule:

[pipes.hackernews]
source = "hackernews"
destination = "email"
email_to = ["you@example.com"]
cron = "0 7 * * *"    # every day at 7 am

Standard cron syntax and Go duration shortcuts are both supported:

ExpressionMeaning
"0 7 * * 1"Every Monday at 7 am
"@daily"Once a day at midnight
"@every 6h"Every 6 hours

Set run-at-startup = true in the top-level config to run all pipes once immediately on startup — useful for Docker deployments.

Use goeland purge (or auto-purge = true) to periodically clean up the unseen database.

Email

[email]
host = "smtp.example.com"
port = 587
username = "user"
password = "p4ssw0rd"
# password_file = /run/password/goeland_smtp_pass
encryption = "tls"
allow-insecure = false
authentication = "plain"    # none | plain | login | crammd5
#Email customization
include-header = true
include-footer = true
#footer = Your custom footer
#logo = internal:goeland.png
#template = /path/to/template.html

authentication defaults to "plain". See go-simple-mail for details on each option.

You can provide a custom HTML email template — see templates.md. A pipe-level template takes precedence over the one in [email].

Rate limiting

Some servers restrict request frequency. Add a global sleep-interval to wait between source fetches:

sleep-interval = "3s"

Uses Go's duration format: "500ms", "3s", "1m30s". Defaults to "0s" (no delay).

Logging

Logs go to stderr. Set the level with the top-level loglevel key, the --loglevel flag or the GOELAND_LOGLEVEL environment variable:

loglevel = "info"
LevelShows
noneNothing (default)
errorErrors only
warningWarnings and errors
infoPipe execution, entry counts per source, files written
debugOne line per filter (entries in → out, duration), entry titles when a filter changes the count, each file written
traceEverything above plus full entry dumps (including raw HTML content) after each filter

Set json-logs = true for JSON-formatted log lines, convenient for log collectors.

Examples

Daily HackerNews digest

[sources.hackernews]
url = "https://hnrss.org/newest"
type = "feed"
filters = ["unseen", "today", "digest"]

[pipes.hackernews]
source = "hackernews"
destination = "email"
email_to = ["you@example.com"]
email_from = "HackerNews <goeland@example.com>"
cron = "@daily"

Latest posts from a subreddit

[sources.reddit]
url = "https://www.reddit.com/r/selfhosted/top.rss"
type = "feed"
filters = ["unseen", "includelink", "digest"]

[pipes.reddit]
source = "reddit"
destination = "email"
email_to = ["you@example.com"]
email_from = "Reddit <goeland@example.com>"

Puppies in your inbox

Merge an RSS bridge and Imgur into one daily delivery:

[sources.insta]
url = "https://rssbridge.example.com/?action=display&bridge=Instagram&context=Hashtag&h=puppy&media_type=picture&direct_links=on&format=MRss"
type = "feed"
filters = ["random(3)"]

[sources.imgur]
type = "imgur"
tag = "puppy"
filters = ["random(3)"]

[sources.puppies]
type = "merge"
sources = ["insta", "imgur"]
filters = ["combine"]

[pipes.puppies]
source = "puppies"
destination = "email"
email_to = ["puppylover@example.com"]
email_from = "DailyPuppy <goeland@example.com>"
cron = "@daily"

Six puppies, delivered.

You can send to multiple recipients by listing them:

email_to = ["bob@example.com", "alice@gmail.com", "charles@yahoo.com"]

See the examples/ folder for more ready-to-use configurations.

Contributing

Feel free to open issues or PRs for bugs, new filters, and new source types. If you encounter a problematic feed, please open an issue with the feed content attached.

Roadmap

Things that could be nice to have:

  • Image inliner
  • Embedded scripting language for filters & manipulation
  • Remove tags for Instagram sources
  • Use feed enclosure as header image
  • go-readability integration