Haraka와 Astro를 사용해 일회용 이메일 서비스를 구축한 방법
Source: Dev.to
I wanted a throwaway email service that wasn’t slow, wasn’t covered in ads, and worked in my language. Nothing I found checked all three boxes, so I built one.
저는 느리지 않고, 광고가 없으며, 제 언어로 작동하는 일회용 이메일 서비스를 원했습니다. 찾은 서비스는 이 세 가지 조건을 모두 만족하지 못했기에 직접 만들었습니다.
The stack is Haraka for SMTP, Redis for real‑time message delivery, SQLite for storage, and Astro for the frontend deployed on Cloudflare Pages. The whole thing runs on a single VPS plus Cloudflare’s edge network.
스택은 SMTP를 위한 Haraka, 실시간 메시지 전달을 위한 Redis, 저장을 위한 SQLite, 그리고 Cloudflare Pages에 배포된 프런트엔드용 Astro로 구성됩니다. 전체 시스템은 단일 VPS와 Cloudflare 엣지 네트워크에서 동작합니다.
Why Haraka
Most guides for “build your own email server” point you at Postfix or something similar. I went with Haraka because it’s Node.js, which meant I could write plugins in a language I already knew instead of learning Postfix configuration files, which feel like they were designed to punish you.
‘자신만의 이메일 서버를 구축하라’는 대부분의 가이드는 Postfix와 같은 것을 추천합니다. 저는 Haraka를 선택했는데, 이는 Node.js 기반이라 이미 알고 있던 언어로 플러그인을 작성할 수 있었고, Postfix 설정 파일을 배우는 대신에 더 친숙했기 때문입니다. Postfix 설정 파일은 마치 사용자를 벌주기 위해 만든 것처럼 느껴졌습니다.
Haraka handles inbound SMTP. When an email arrives, a custom plugin checks if the recipient address exists in the active session pool. If it does, the message gets parsed and pushed to Redis pub/sub. If it doesn’t, the email gets rejected.
Haraka는 인바운드 SMTP를 처리합니다. 이메일이 도착하면 커스텀 플러그인이 수신자 주소가 현재 세션 풀에 존재하는지 확인합니다. 존재한다면 메시지를 파싱하여 Redis pub/sub에 푸시하고, 존재하지 않으면 이메일을 거부합니다.
The plugin is about 80 lines. It extracts the sender, subject, HTML body, and attachments, then publishes a JSON payload to a Redis channel keyed by the recipient address.
플러그인은 약 80줄 정도이며, 발신자, 제목, HTML 본문, 첨부파일을 추출한 뒤 수신자 주소를 키로 하는 Redis 채널에 JSON 페이로드를 퍼블리시합니다.
Real‑time delivery with Redis
The frontend opens a Server‑Sent Events (SSE) connection when you load the page. On the backend, there’s a listener subscribed to the Redis channel matching your temporary address. When an email hits Haraka and gets published to Redis, the SSE connection pushes it to the browser immediately.
프런트엔드는 페이지를 로드할 때 Server‑Sent Events (SSE) 연결을 엽니다. 백엔드에서는 임시 주소에 해당하는 Redis 채널을 구독하는 리스너가 있습니다. 이메일이 Haraka에 도착해 Redis에 퍼블리시되면 SSE 연결을 통해 브라우저로 즉시 전달됩니다.
I tried WebSockets first but SSE was simpler for a one‑directional flow. You don’t need to send messages back to the server, you just need to receive emails. SSE reconnects automatically on connection drops, which is one less thing to handle.
처음에는 WebSocket을 시도했지만, 단방향 흐름에는 SSE가 더 간단했습니다. 서버에 메시지를 보낼 필요 없이 이메일만 받으면 되기 때문입니다. SSE는 연결이 끊어지면 자동으로 재연결되므로 관리해야 할 사항이 하나 줄어듭니다.
SQLite for storage
Emails get stored in SQLite with a TTL. A background job runs every minute and deletes anything past its expiration. I considered Postgres but SQLite was enough for this scale and means one less service to manage on the VPS.
이메일은 TTL을 가진 SQLite에 저장됩니다. 백그라운드 작업이 매분 실행되어 만료된 데이터를 삭제합니다. Postgres도 고려했지만, 이 규모에서는 SQLite가 충분했고 VPS에서 관리해야 할 서비스가 하나 줄어들었습니다.
The schema is minimal:
스키마는 최소한으로 구성됩니다:
idrecipientsendersubjecthtml_bodytext_bodyattachments(JSON)created_atexpires_at
Astro on Cloudflare Pages
The frontend is Astro with islands of React for the interactive parts (inbox, email viewer, timer). Static pages (blog, about, privacy, terms, contact) are fully static HTML generated at build time, which is great for SEO.
프런트엔드는 인터랙티브한 부분(받은 편지함, 이메일 뷰어, 타이머)을 위해 React 섬을 포함한 Astro로 구성됩니다. 정적 페이지(블로그, 소개, 개인정보 처리방침, 이용 약관, 연락처)는 빌드 시점에 완전한 정적 HTML로 생성되어 SEO에 유리합니다.
I went with Cloudflare Pages because the deploy is just a git push. No Docker, no CI pipeline to maintain. The site builds in under 3 seconds for 400+ pages.
배포가 git 푸시만으로 이루어지는 Cloudflare Pages를 선택했습니다. Docker도 CI 파이프라인도 관리할 필요가 없습니다. 사이트는 400개 이상의 페이지를 3초 이하로 빌드합니다.
20 languages without a translation team
The site works in 20 languages. I used i18n JSON files with a structure like translations/en.json, translations/pt‑br.json, etc. Each file has the same keys, different values. The routing is /[lang]/page, so /pt‑br/blog gives you the Portuguese blog.
사이트는 20개 언어를 지원합니다. translations/en.json, translations/pt‑br.json 등과 같은 구조의 i18n JSON 파일을 사용했습니다. 각 파일은 동일한 키를 가지고 값만 다릅니다. 라우팅은 /[lang]/page 형태이며, /pt‑br/blog은 포르투갈어 블로그를 제공합니다.
Blog posts are in Astro content collections, one markdown file per language per post. A single post exists as 20 files: en/what-is-temp-mail.md, pt‑br/what‑is‑temp‑mail.md, etc. It’s a lot of files but the build handles it fine.
블로그 글은 Astro 콘텐츠 컬렉션에 저장되며, 포스트당 언어별로 하나의 markdown 파일이 있습니다. 하나의 포스트는 20개의 파일(en/what-is-temp-mail.md, pt‑br/what‑is‑temp‑mail.md 등)로 존재합니다. 파일 수가 많지만 빌드가 잘 처리합니다.
For the actual translations, I used AI to generate first drafts and then had native speakers review the top 5 languages by traffic. The long‑tail languages get fewer eyes but having them there at all helps with international SEO.
실제 번역은 AI로 초안을 만든 뒤 트래픽 상위 5개 언어에 대해 원어민이 검수하도록 했습니다. 롱테일 언어는 검토가 적지만, 존재 자체가 국제 SEO에 도움이 됩니다.
What I’d do differently
Redis pub/sub works but doesn’t persist messages if no one is listening. If the user opens the page after the email already arrived, they won’t see it unless it was also written to SQLite. I handle this by querying SQLite on page load and then switching to SSE for real‑time updates. It works, but it means every email gets written twice (SQLite + Redis). In hindsight, I might use Redis Streams instead of pub/sub to get persistence and real‑time in one place.
Redis pub/sub은 동작하지만 청취자가 없을 경우 메시지를 영구히 저장하지 않습니다. 사용자가 이메일이 도착한 뒤 페이지를 열면 SQLite에 기록되지 않은 경우 해당 이메일을 볼 수 없습니다. 이를 해결하기 위해 페이지 로드 시 SQLite를 조회하고 이후 실시간 업데이트는 SSE로 전환합니다. 동작은 하지만 모든 이메일이 두 번 저장됩니다(SQLite + Redis). 되돌아보면 pub/sub 대신 Redis Streams를 사용해 영속성과 실시간성을 한 곳에서 처리했을 것입니다.
The site is https://trashbox.email/ if you want to try it. The source isn’t open but I’m happy to answer questions about the architecture.
사이트는 https://trashbox.email/ 이며, 직접 사용해볼 수 있습니다. 소스는 공개되지 않았지만 아키텍처에 관한 질문에 기꺼이 답변해 드리겠습니다.