메뉴
HN
Hacker News 4일 전

포스트혼: 메일 서버 없는 셀프 호스팅 메일 통합 게이트웨이

IMP
7/10
핵심 요약

셀프 호스팅 프로젝트를 위한 통합 발신 메일 릴레이 계층인 '포스트혼(Posthorn)'이 소개되었습니다. 이 도구는 사용자가 직접 메일 서버를 운영할 필요 없이, 다양한 앱의 발신 메일을 하나의 설정과 자격 증명으로 관리하여 Postmark, Resend 등의 트랜잭셔널 메일 제공자로 안전하게 중계합니다. 단일 Go 바이너리와 TOML 설정만으로 이메일 통합의 복잡성을 크게 줄여주는 오픈소스 솔루션입니다.

번역된 본문

포스트혼(Posthorn): 셀프 호스팅 프로젝트를 위한 통합 발신 메일 계층

당신이 셀프 호스팅하는 모든 앱과 이미 선택한 트랜잭셔널 메일 제공자(Provider) 사이를 잇는 단 하나의 게이트웨이입니다. 3가지 수신 방식(HTTP 폼, HTTP API, SMTP), 5가지 전송 수단(Postmark, Resend, Mailgun, AWS SES, 아웃바운드 SMTP 릴레이)을 지원하며, 단일 Go 바이너리와 단일 TOML 설정 파일로 작동합니다.

실제 활용 사례: Hugo + Comentario · Ghost · Gitea · Umami 다이제스트 크론 · Cloudflare Worker

왜 이 도구가 필요한가? 2026년에 아무도 메일 서버를 직접 운영하고 싶어 하지 않습니다. 셀프 호스팅 운영자들은 Postmark, Resend, Mailgun 또는 AWS SES를 사용합니다. 저렴하고, 이메일 전달률을 적절히 처리해 주며, SPF / DKIM / DMARC / 반송 이메일 / 발신자 평판 관리와 같은 골치 아픈 일을 대신해 주기 때문입니다.

하지만 셀프 호스팅하는 모든 앱은 해당 서비스와 독립적으로 연동해야 합니다. 연락처 폼, Ghost 블로그의 관리자 이메일, Gitea 매직 링크, Mastodon 알림, 누군가 링크를 클릭할 때 비밀번호 재설정 이메일을 발송하는 Cloudflare Worker까지 모두 각각의 처리가 필요합니다. 각 앱은 고유한 API 키 복사본, 고유한 연동 코드, 재시도 및 반송 처리에 대한 고유한 예외 상황이 필요합니다. 동일한 발신 처리 로직이 스택 전체에 중복되는 것입니다. 게다가 아웃바운드 SMTP를 차단하는 클라우드 호스팅(DigitalOcean, AWS Lightsail, Linode, Vultr)에서는 SMTP만 지원하는 앱이 우회 방법 없이는 아예 작동하지 않습니다.

포스트혼이 이 간극을 메워주는 다리 역할을 합니다. 하나의 컨테이너, 하나의 설정, 하나의 자격 증명 세트면 충분합니다. 당신의 앱은 포스트혼을 바라보고, 포스트혼은 당신의 메일 제공자와 통신합니다.

앱의 연결 방식에 따른 포스트혼의 역할:

  • HTTP 폼 (연락처 폼, 가입, 알림 웹훅): 허니팟(Honeypot) + Origin/Referer 확인 + 속도 제한 + 선택적 CSRF 적용, 이메일 템플릿화, 발송
  • HTTP API 모드 (워커, 크론, 결제 핸들러, 내부 서비스): Authorization: Bearer 인증, JSON 본문, 멱등성(Idempotent) 재시도, 트랜잭셔널 발송을 위한 per-request to_override 지원
  • SMTP 리스너 (Ghost, Gitea, Mastodon, Matrix, NextCloud, Authentik 등 SMTP를 내보내는 모든 앱): AUTH PLAIN 또는 클라이언트 인증서, STARTTLS 필수, 발신자 + 수신자 허용 목록, MIME 파싱, HTTP API를 통한 전달

이 세 가지 수신 경로는 하나의 transport.Message와 하나의 아웃바운드 제공자(Postmark, Resend, Mailgun, AWS SES 또는 아웃바운드 SMTP 릴레이 중 선택)로 통합됩니다.

포스트혼이 아닌 것들 (잘못된 접근을 피하기 위한 안내):

  • 메일 서버가 아닙니다: 메일함 스토리지, IMAP/JMAP, DKIM 키 관리, MX 타겟 기능이 없습니다. (대안: Stalwart, Mailcow, iRedMail)
  • 자체 아웃바운드 인프라가 아닙니다: 포스트혼은 사용자가 선택한 제공자를 통해 릴레이할 뿐, 자체 SMTP 클러스터를 운영하거나 IP 평판을 관리하지 않습니다. (대안: Postal, Hyvor Relay)
  • 마케팅 이메일 플랫폼이 아닙니다: 목록 관리, 세분화, 캠페인 대시보드 기능이 없습니다. (대안: Listmonk)
  • 웹메일 / 메일함 UI가 아닙니다: 이메일을 읽기 위한 인터페이스가 없습니다. (대안: Roundcube, Snappymail - 메일 서버와 함께 사용)

이 도구는 셀프 호스팅 앱과 이미 선택한 트랜잭셔널 제공자 사이의 통합 계층 역할을 하는 핵심 매개체입니다.

문서화: posthorn.dev에서 시작하기, 설정 참조, 배포 가이드, 기능 심층 분석, 보안 모델, HTTP API 참조, FAQ를 확인할 수 있습니다. 연락처 폼, 뉴스레터 가입, 다중 폼 사이트, 모니터링 경고, Cloudflare Workers, 내부 SMTP 릴레이(Docker Compose)를 다루는 10가지 레시피와 Hugo+Comentario, Ghost, Gitea 및 셀프 호스팅 Umami 다이제스트에 대한 전체 사례 연구가 포함되어 있습니다. 프로젝트 기록 및 v1.0 사양은 spec/ 디렉터리를 참조하십시오.

빠른 시작 (Docker):

docker-compose.yml

services: posthorn: image: ghcr.io/craigmccaskill/posthorn:latest restart: unless-stopped volumes: - ./posthorn.toml:/etc/posthorn/config.toml:ro environment: POSTMARK_API_KEY: ${POSTMARK_API_KEY} ports: - "127.0.0.1:8080:8080" # 루프백에 바인딩, 전면 프록시(Reverse-proxy)에서 연결

posthorn.toml

[[endpoints]] path = "/api/contact" to = ["you@example.com"] from = "Contact Form noreply@example.com" honeypot = "_gotcha" allowed_origins = ["https://example.com"] required = ["name", "email", "message"] subject = "Contact from {{.name}}" body = """ From: {{.name}} <{{.email}}> {{.message}} """ redirect_success = "/thank-you"

[endpoints.transport] type = "postma...

원문 보기
원문 보기 (영어)
Posthorn The unified outbound mail layer for self-hosted projects. One gateway between every app you self-host and the transactional mail provider you've already picked. Three ingress shapes (HTTP form, HTTP API, SMTP), five transports (Postmark, Resend, Mailgun, AWS SES, outbound-SMTP relay), single Go binary, single TOML config. Real-world stacks: Hugo + Comentario · Ghost · Gitea · Umami digest cron · Cloudflare Worker Why Nobody wants to run a mail server in 2026. Self-hosted operators use Postmark, Resend, Mailgun, or AWS SES because they're cheap, they handle deliverability properly, and someone else worries about SPF / DKIM / DMARC / bounces / sender reputation. But every app you self-host has to integrate with that service independently. Your contact form. Your Ghost blog's admin emails. Your Gitea magic links. Your Mastodon notifications. The Cloudflare Worker that fires a password-reset email when someone clicks the link. Each one needs its own copy of the API key, its own integration code, its own quirks around retry and bounce handling. The same outbound concern duplicated across your stack. And on cloud hosts that block outbound SMTP — DigitalOcean, AWS Lightsail, Linode, Vultr — the SMTP-only apps don't work at all without a workaround. Posthorn is the bridge. One container, one config, one set of credentials. Your apps point at Posthorn. Posthorn talks to your provider. Where your app connects What Posthorn does HTTP form (contact forms, signups, alert webhooks) Honeypot + Origin/Referer + rate limit + optional CSRF; templates the email; sends HTTP API mode (workers, cron, payment handlers, internal services) Authorization: Bearer auth; JSON body; idempotent retries; per-request to_override for transactional sends SMTP listener (Ghost, Gitea, Mastodon, Matrix, NextCloud, Authentik, anything that emits SMTP) AUTH PLAIN or client-cert; STARTTLS-required; sender + recipient allowlists; parses MIME; forwards via HTTP API transport All three ingresses converge on one transport.Message and one outbound provider — pick from Postmark, Resend, Mailgun, AWS SES, or an outbound-SMTP relay. What Posthorn is not To save you a wrong turn: What it does Look at instead Not a mail server No mailbox storage, no IMAP/JMAP, no DKIM key management, no MX target Stalwart , Mailcow , iRedMail Not its own outbound infrastructure Posthorn relays through a provider you chose; it doesn't run its own SMTP fleet or manage IP reputation Postal , Hyvor Relay Not a marketing email platform No list management, no segmentation, no campaign dashboard Listmonk Not webmail / a mailbox UI No interface for reading mail Roundcube, Snappymail (with a mail server) The wedge is the integration layer between your self-hosted apps and the transactional provider you've already picked. Documentation posthorn.dev — getting started, configuration reference, deployment guides, feature deep-dives, security model, HTTP API reference, FAQ. Ten recipes covering contact forms, newsletter signups, multi-form sites, monitoring alerts, Cloudflare Workers, internal SMTP relay (Docker Compose), and full case studies for Hugo+Comentario, Ghost, Gitea, and self-hosted Umami digests. For project history and the v1.0 spec, see spec/ . Quick start (Docker) # docker-compose.yml services : posthorn : image : ghcr.io/craigmccaskill/posthorn:latest restart : unless-stopped volumes : - ./posthorn.toml:/etc/posthorn/config.toml:ro environment : POSTMARK_API_KEY : ${POSTMARK_API_KEY} ports : - " 127.0.0.1:8080:8080 " # bind to loopback; reverse-proxy from your front door # posthorn.toml [[ endpoints ]] path = " /api/contact " to = [ " you@example.com " ] from = " Contact Form <noreply@example.com> " honeypot = " _gotcha " allowed_origins = [ " https://example.com " ] required = [ " name " , " email " , " message " ] subject = " Contact from {{.name}} " body = """ From: {{.name}} <{{.email}}> {{.message}} """ redirect_success = " /thank-you " [ endpoints . transport ] type = " postmark " [ endpoints . transport . settings ] api_key = " ${env.POSTMARK_API_KEY} " [ endpoints . rate_limit ] count = 5 interval = " 1m " Reverse-proxy /api/contact from your front door (Caddy, nginx, Traefik) to http://posthorn:8080 . Point your form's action at /api/contact . Done. Full walkthrough: posthorn.dev/getting-started/quick-start . API mode (server-to-server) For Workers, cron jobs, internal services — anything that speaks JSON instead of forms: [[ endpoints ]] path = " /api/transactional " to = [ " fallback@yourdomain.com " ] from = " YourApp <noreply@yourdomain.com> " auth = " api-key " api_keys = [ " ${env.WORKER_KEY_PRIMARY} " , " ${env.WORKER_KEY_BACKUP} " ] required = [ " subject_line " , " message " ] subject = " {{.subject_line}} " body = " {{.message}} " [ endpoints . transport ] type = " postmark " [ endpoints . transport . settings ] api_key = " ${env.POSTMARK_API_KEY} " curl -X POST https://posthorn.yourdomain.com/api/transactional \ -H " Authorization: Bearer $WORKER_KEY_PRIMARY " \ -H " Content-Type: application/json " \ -H " Idempotency-Key: reset:user-123: $( date -u +%FT%H ) " \ --data ' { "to_override": "alice@example.com", "subject_line": "Reset your password", "message": "Click here: https://app.example.com/reset/abc" } ' Full walkthrough: posthorn.dev/recipes/cloudflare-worker . SMTP listener (Ghost / Gitea / Mastodon / Authentik) For apps that speak SMTP natively and can't be reconfigured to call an HTTP API: [ smtp_listener ] listen = " :2525 " require_tls = true tls_cert = " /etc/posthorn/cert.pem " tls_key = " /etc/posthorn/key.pem " auth_required = " smtp-auth " allowed_senders = [ " *@yourdomain.com " ] max_recipients_per_session = 10 max_message_size = " 1MB " [[ smtp_listener . smtp_users ]] username = " ghost " password = " ${env.GHOST_SMTP_PASSWORD} " [ smtp_listener . transport ] type = " postmark " [ smtp_listener . transport . settings ] api_key = " ${env.POSTMARK_API_KEY} " Point Ghost (or any app's SMTP config) at posthorn.yourdomain.com:2525 with the username/password above. Posthorn parses the MIME, builds a transport.Message , forwards via Postmark. Full doc: posthorn.dev/features/smtp-ingress . Picking a transport Transport Best for Auth Body Postmark Transactional email, strong deliverability defaults X-Postmark-Server-Token JSON Resend Modern HTTP API, developer-friendly dashboard Authorization: Bearer JSON Mailgun Higher-volume transactional, US + EU regions HTTP Basic multipart/form-data AWS SES AWS-native deployments, cheapest at volume AWS SigV4 (bespoke) JSON Outbound SMTP Any STARTTLS-capable relay (Mailtrap, your Postfix smarthost, etc.) AUTH PLAIN SMTP DATA Switching providers is a TOML edit — every transport implements the same Transport interface. See posthorn.dev/configuration/transports for per-provider config. Production checklist Before pointing real traffic at Posthorn: DNS — SPF, DKIM, and DMARC records on your sending domain. Without these your mail goes to spam. See posthorn.dev/security/dns . Reverse proxy — Posthorn does not terminate TLS. Run it behind Caddy, nginx, or Traefik. See posthorn.dev/deployment/reverse-proxy . allowed_origins (form-mode endpoints) — set this to lock submissions to your domain. Without it, anyone can POST to your endpoint. rate_limit — set a tight bucket per endpoint (5/minute is a sensible default for a public contact form; API mode rate-limits per matched key). trusted_proxies — if behind a reverse proxy, list its CIDR (or use the cloudflare named preset) so the rate limiter sees the real client IP. /healthz and /metrics — auto-registered on the same listener. Wire your Docker healthcheck or Prometheus scrape to these. The full operator checklist is on posthorn.dev . What's in v1.0 Block Detail Form ingress Form-encoded + multipart bodies; honeypot, Origin/Referer fail-closed, rate limit, optional CSRF tokens API mode auth = "api-key" with Bearer tokens (constant-time compare); JSON content type; idempotency keys (24h, in-memor