Gmail
The poller
Run a separate process that checks the inbox on an interval, handles each new message, and labels it so it is never picked up twice.
By the end of this page a second process runs next to the API. Every POLL_INTERVAL_SECONDS it lists new inbox mail, logs it, and adds the ai-processed label.
Polling or webhooks
Gmail can also push new mail to your server through a webhook. That needs Google Pub/Sub, a public HTTPS address, and a domain. Polling only makes outgoing calls, so nothing has to reach your server. For email support a delay of a minute or ten is fine. Switch to webhooks when you need instant replies or high volume.
Handle one message
For now the pipeline logs the email and labels it. The next pages add the database and the replies.
import logging
from app.gmail import PROCESSED_LABEL, Gmail, get_headers
logger = logging.getLogger(__name__)
def handle_message(gmail: Gmail, message_id: str) -> None:
message = gmail.get_message(message_id)
headers = get_headers(message)
logger.info("New email from %s: %s", headers.get("from"), headers.get("subject"))
gmail.add_label(message_id, PROCESSED_LABEL)Write the poller
import logging
import time
from app.config import settings
from app.gmail import Gmail
from app.pipeline import handle_message
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
logger = logging.getLogger("poller")
def poll_once() -> int:
gmail = Gmail()
message_ids = gmail.list_new_message_ids()
for message_id in message_ids:
try:
handle_message(gmail, message_id)
except Exception:
# One bad email must not stop the loop.
logger.exception("Failed to process %s", message_id)
return len(message_ids)
def main() -> None:
logger.info("Polling every %s seconds", settings.poll_interval_seconds)
while True:
try:
count = poll_once()
if count:
logger.info("Handled %s new messages", count)
except Exception:
logger.exception("Poll failed")
time.sleep(settings.poll_interval_seconds)
if __name__ == "__main__":
main()poll_once does one pass, and main repeats it forever. A message that fails keeps no label, so the next pass tries it again.
Run it
Use two terminals from now on, one for the API and one for the poller.
uv run python -m app.pollerThe poller runs as its own process, not inside FastAPI. With several API workers you would get several pollers, and the Gmail client is synchronous, so it would block the API while it waits.
Check
Send two emails to the demo address and watch the poller terminal.
2026-10-28 10:00:00 INFO poller Polling every 30 seconds
2026-10-28 10:00:31 INFO app.pipeline New email from Alex <alex@example.com>: Paddle board size
2026-10-28 10:00:31 INFO app.pipeline New email from Alex <alex@example.com>: Missing screws
2026-10-28 10:00:31 INFO poller Handled 2 new messagesIn Gmail both emails carry the ai-processed label. On the next pass the poller finds nothing, because the search skips that label. Stop the poller with Ctrl+C.