{"id":50320684,"url":"https://github.com/parithosh/mail2rss","last_synced_at":"2026-05-29T03:04:52.655Z","repository":{"id":357334042,"uuid":"1236457730","full_name":"parithosh/mail2rss","owner":"parithosh","description":"A tool to convert emails (via JMAP) to RSS feeds","archived":false,"fork":false,"pushed_at":"2026-05-12T11:24:59.000Z","size":82,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-12T11:25:19.682Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/parithosh.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-12T09:07:12.000Z","updated_at":"2026-05-12T11:25:05.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/parithosh/mail2rss","commit_stats":null,"previous_names":["parithosh/mail2rss"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/parithosh/mail2rss","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/parithosh%2Fmail2rss","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/parithosh%2Fmail2rss/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/parithosh%2Fmail2rss/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/parithosh%2Fmail2rss/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/parithosh","download_url":"https://codeload.github.com/parithosh/mail2rss/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/parithosh%2Fmail2rss/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33634615,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-29T02:00:06.066Z","response_time":107,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-05-29T03:04:52.444Z","updated_at":"2026-05-29T03:04:52.647Z","avatar_url":"https://github.com/parithosh.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# mail2rss\n\nA small Fastmail → Atom bridge. Point it at a folder in your Fastmail account,\nget an RSS/Atom feed of the messages in it. Useful for newsletters (Substack\nand others), mailing lists, or any service that emails you instead of offering\na feed.\n\n- Polls Fastmail over JMAP, no IMAP and no third-party relays.\n- Sanitizes the HTML before serving it (no scripts, no tracking pixels).\n- Stores entries in SQLite so historical posts stay in the feed even if the\n  source mail is later deleted.\n- Serves Atom 1.0 over HTTP for FreshRSS / Miniflux / NetNewsWire / etc.\n- Per-publication feeds (auto-detected from `List-ID` / `From`) plus an\n  aggregate `all.xml`.\n\n## Quickstart (Docker Compose)\n\n```sh\ngit clone https://github.com/parithosh/mail2rss.git\ncd mail2rss\n\n# 1. Fastmail API token (read-only, mail scope is enough)\ncp .env.example .env\n$EDITOR .env          # set FASTMAIL_TOKEN=fmu1-...\n\n# 2. Pick a mailbox + tweak settings\ncp config.example.toml config.toml\n$EDITOR config.toml   # at minimum: [fastmail].mailbox\n\n# 3. Run\ndocker compose up -d --build\n\n# 4. Find the feed URLs (logged on every startup)\ndocker compose logs mail2rss | grep feed_urls_ready\n```\n\nYou'll see something like:\n\n```json\n{\"event\": \"feed_urls_ready\", \"bind\": \"0.0.0.0:8080\", \"paths\": [\"/feeds/\u003csecret\u003e/all.xml\", \"/feeds/\u003csecret\u003e/\u003cpub\u003e.xml\"]}\n```\n\nSubscribe your reader to `http://\u003chost\u003e:8080/feeds/\u003csecret\u003e/all.xml`.\n\n## Fastmail setup\n\n1. Create an API token in Fastmail settings → Privacy \u0026 Security → API tokens.\n2. Give it **JMAP core** + **Mail (read-only)** scopes.\n3. (Optional but recommended) Set up a Sieve rule that files newsletter mail\n   into a dedicated folder, e.g. `Newsletters` or `Substack`. Point\n   `[fastmail].mailbox` at that folder name. The lookup is exact-match.\n\nThe token is never written to disk or logs — only the four-character prefix\n(`fmu1`) is logged so you can confirm the right one loaded.\n\n## Configuration\n\n`config.toml` keys (full example in `config.example.toml`):\n\n| Section / key | Default | Notes |\n| --- | --- | --- |\n| `[fastmail].mailbox` | `Substacks` | Exact folder name in Fastmail. |\n| `[fastmail].token_env` | `FASTMAIL_TOKEN` | Env var to read the token from. |\n| `[poll].interval_seconds` | `900` | 15 min. Min 30 s. |\n| `[poll].initial_backfill` | `50` | Messages pulled on first sync. |\n| `[output].dir` | `/var/lib/mail2rss/feeds` | Where Atom files are written. |\n| `[output].max_entries_per_feed` | `100` | Per `\u003cslug\u003e.xml` and `all.xml`. |\n| `[http].bind` | `127.0.0.1:8080` | Use `0.0.0.0:8080` with Docker port mapping. |\n| `[http].require_secret` | `true` | See **Feed URL security** below. |\n| `[filters].*` | all off | See **Filtering**. |\n| `[log].level` / `[log].format` | `info` / `json` | |\n\n### Feed URL security\n\nBy default, feeds live at `/feeds/\u003crandom-secret\u003e/all.xml`. The secret is\ngenerated on first run, stored in SQLite, and treated as a bearer token\nembedded in the URL — anyone with the URL can read the feed. The full path\nlist is logged at boot under the `feed_urls_ready` event.\n\nFor local-only deployments, you can drop the secret:\n\n```toml\n[http]\nbind = \"127.0.0.1:8080\"\nrequire_secret = false\n```\n\nFeeds then live at `/feeds/all.xml` and `/feeds/\u003cslug\u003e.xml`. Only do this when\nthe bind address is unreachable from the outside (loopback, private network,\nor a `127.0.0.1:8080:8080` Docker port mapping).\n\nIf you expose `mail2rss` publicly, either keep the secret on or put proper\nauth (Traefik / Caddy / nginx basic-auth, OAuth proxy, VPN, etc.) in front of\nit.\n\n### Filtering\n\n`mail2rss` is a generic email → RSS bridge: by default every message in the\nconfigured mailbox becomes a feed entry. The `[filters]` block lets you opt\ninto per-source noise filters without baking assumptions into the parser.\n\n```toml\n[filters]\nrequire_list_id = false        # drop mail without a List-ID header\nrequire_canonical_url = false  # drop mail when the parser can't extract an article URL\nsubject_blocklist = []         # case-insensitive substring match on Subject\nfrom_blocklist = []            # case-insensitive substring match on the From address\n```\n\nCombinations that work well in practice:\n\n- **Substack-only mailbox**: `require_canonical_url = true` catches most\n  transactional mail (verification codes, payment receipts, \"Premium active\",\n  recommendations) because they don't link to a `/p/\u003cslug\u003e` post page.\n- **Mailing list / Google Groups**: `require_list_id = true` filters out\n  direct replies and one-off mail to the same address.\n- **Mixed sources**: layer `subject_blocklist = [\"verification code\", \"payment\n  receipt\"]` and `from_blocklist = [\"no-reply@\", \"billing@\"]` on top.\n\nSkipped messages are logged at info as `mail_skipped` with a `reason` field\n(`missing_list_id`, `missing_canonical_url`, `subject_blocked:\u003cpattern\u003e`,\n`from_blocked:\u003cpattern\u003e`) so you can iterate on rules from real data.\n\n## Health and observability\n\n`/healthz` returns JSON with the daemon's current state — useful for Docker\nhealthchecks, k8s probes, or just a quick `curl` from the host:\n\n```json\n{\n  \"healthy\": true,\n  \"started_at\": \"...\",\n  \"last_successful_poll\": \"...\",\n  \"last_failed_poll\": null,\n  \"last_error\": null,\n  \"current_backoff_seconds\": 0.0,\n  \"shutting_down\": false\n}\n```\n\nHealth goes degraded before the first successful poll, and if the last\nsuccess is older than twice the poll interval.\n\nWhen called from `127.0.0.1`/`::1`, `/healthz?show_url=1` also returns the\ncurrent feed paths — handy if you've lost the boot log.\n\nLogs are structured JSON; key events:\n\n| event | meaning |\n| --- | --- |\n| `feed_urls_ready` | Atom files written and HTTP server is up. |\n| `poll_completed` | Per-cycle summary: `fetched_count`, `inserted_count`, `skipped_count`, `feed_count`. |\n| `mail_skipped` | An incoming mail was dropped by `[filters]`; includes `reason`. |\n| `poll_failed` | Polling errored; includes `backoff_seconds` until retry. |\n| `canonical_url_backfilled` | Older entries got article URLs after a parser improvement. |\n\nEmail-identifying fields in logs are salted-hashed; raw subjects, addresses,\nbodies, and JMAP/Message-IDs never appear.\n\n## Running without Docker\n\n```sh\nuv sync\nFASTMAIL_TOKEN=fmu1-... uv run mail2rss --config config.toml\n```\n\nIf `--config` is omitted, built-in defaults are used. SQLite lands at\n`/var/lib/mail2rss/mail2rss.db` by default; override with `db_path` at the\ntop level of `config.toml` if you'd rather keep it elsewhere.\n\n## Development\n\n```sh\nuv sync --all-extras\nuv run ruff check\nuv run ruff format --check\nuv run mypy src tests\nuv run pytest\n```\n\nTest fixtures under `tests/fixtures/` must be anonymized — never commit real\nemail addresses, private article text, unsubscribe links, or raw JMAP/blob\nIDs. See `tests/fixtures/README.md`.\n\n## Security notes\n\n- The Fastmail token is read from an environment variable only and never\n  persisted by the daemon.\n- SQLite is created with `0600` permissions.\n- Atom files are written via atomic temp-file replace so readers never see a\n  truncated feed.\n- HTML is sanitized via `bleach` with a strict allowlist (no scripts, no\n  iframes, no event handlers). Substack tracking pixels and unsubscribe\n  footers are stripped before sanitization.\n- Already-published entries stay in feeds even if the source mail is later\n  deleted from the Fastmail folder — readers won't randomly lose history.\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fparithosh%2Fmail2rss","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fparithosh%2Fmail2rss","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fparithosh%2Fmail2rss/lists"}