Compare commits

..
8 Commits
Author SHA1 Message Date
soconnorandClaude Sonnet 5 64d795173c Copy sent mail to Sent folder; add message field to Poke webhook
CI / lint (push) Failing after 5s
CI / build (push) Successful in 5s
CI / docker (push) Skipped
Build & Push Container Image / build-and-push (push) Successful in 30s
- send_email only relayed via SMTP, which doesn't copy the message
  to Sent the way a provider's own webmail/Mail app does — that's a
  client-side IMAP APPEND step that was simply missing, so sent mail
  never showed up anywhere in the account. Added detect_sent_folder
  (mirrors detect_drafts_folder) and append the sent copy after a
  successful SMTP send.
- forward_to_poke's payload never included a top-level "message"
  field, which every documented /api/v1/inbound/api-message example
  uses to give the agent something actionable — without it the
  webhook could get accepted (200/success) but ingested as inert
  context with nothing to surface. Added _build_poke_message() to
  generate one from the email's from/subject/body.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 01:56:58 -04:00
soconnorandClaude Sonnet 5 3e816beb6e Speed up CI: switch lint to ruff-action, drop broken pip cache
CI / lint (push) Successful in 8s
CI / build (push) Successful in 4s
Build & Push Container Image / build-and-push (push) Successful in 29s
CI / docker (push) Successful in 15s
- lint job now uses astral-sh/ruff-action instead of provisioning a
  full Python toolchain via setup-python just to pip install ruff —
  the action just downloads the standalone Ruff binary.
- build job's cache: pip was hanging on every run: the Gitea runner
  has no reachable Actions-cache backend, so setup-python's cache
  restore step was timing out on a connect (ETIMEDOUT) before
  falling back and continuing anyway. Removed since deps here are
  tiny and a cold install costs seconds, not minutes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 18:25:56 -04:00
soconnorandClaude Sonnet 5 9f26098954 Fix Poke webhook: verify success field, point key setup to Kitchen
CI / build (push) Canceled after 0s
CI / docker (push) Canceled after 0s
Build & Push Container Image / build-and-push (push) Canceled after 0s
CI / lint (push) Canceled after 7m3s
- forward_to_poke now checks the response's success field instead of
  trusting the HTTP status alone; Poke can return 200 with
  success: false on a soft failure, which was previously logged and
  treated as delivered.
- README/start.sh pointed users to Settings > Advanced for the API
  key, which issues a legacy pk_ key incompatible with the
  inbound/api-message endpoint this project uses. Now points to
  poke.com/kitchen -> API Keys for a V2 key.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 18:18:56 -04:00
soconnor 35e4d3da86 fix: make docker build/push work with Gitea
CI / lint (push) Successful in 9m22s
CI / build (push) Successful in 9m21s
Build & Push Container Image / build-and-push (push) Successful in 48s
CI / docker (push) Successful in 23s
- Replace type=gha cache (broken on Gitea: unparseable runtime token)
  with type=registry cache using image-manifest/oci-mediatypes options
  that the Gitea registry accepts.
- Push with a Gitea PAT (REGISTRY_TOKEN) since the auto job token
  cannot publish OCI images to the package repo.
- Add registry login to the CI docker smoke-test job.
2026-08-02 16:54:15 -04:00
soconnor 6d1d274457 fix: make ruff lint pass in CI
CI / lint (push) Successful in 4s
CI / build (push) Successful in 9m20s
Build & Push Container Image / build-and-push (push) Failing after 10s
CI / docker (push) Successful in 28s
Add .ruff.toml ignoring BLE001/S110 (intentional mail parsing), apply
ruff auto-fixes (imports, Optional->X|None), and fix format, shebang and
PORT env default.
2026-08-02 16:23:46 -04:00
soconnor b3e44d8ffb fix: update branch references from main to master in CI and publish workflows
CI / lint (push) Failing after 41s
CI / build (push) Successful in 9m33s
CI / docker (push) Skipped
Build & Push Container Image / build-and-push (push) Failing after 2m13s
2026-08-02 16:09:26 -04:00
soconnor a04bd8dedb feat: add CI and publish workflows for container image 2026-08-02 16:07:38 -04:00
soconnor eee315fe3d fix read-only behavior 2026-08-02 16:07:16 -04:00
6 changed files with 207 additions and 37 deletions
+48
View File
@@ -0,0 +1,48 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/ruff-action@v3
with:
args: check src/
- uses: astral-sh/ruff-action@v3
with:
args: format --check src/
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: pip install -r requirements.txt
- name: Verify imports
run: python -c "from src.server import mcp; print('OK:', mcp.name)"
docker:
runs-on: ubuntu-latest
needs: [lint, build]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: git.soconnor.dev
username: ${{ gitea.repository_owner }}
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: false
tags: poke-mail:test
cache-from: type=registry,ref=git.soconnor.dev/soconnor/poke-mail:buildcache
+44
View File
@@ -0,0 +1,44 @@
name: Build & Push Container Image
on:
push:
branches: [master]
tags: ["v*"]
env:
REGISTRY: git.soconnor.dev
IMAGE_NAME: soconnor/poke-mail
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.repository_owner }}
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max,image-manifest=true,oci-mediatypes=true
+5
View File
@@ -0,0 +1,5 @@
target-version = "py310"
[lint]
# Blind excepts and silent cleanup in mail parsing are intentional here.
ignore = ["BLE001", "S110"]
+2 -2
View File
@@ -61,11 +61,11 @@ Set up poke-mail (https://github.com/kacperkwapisz/poke-mail) for me — clone t
cp config.example.yml config.yml cp config.example.yml config.yml
``` ```
Edit `config.yml` with your email credentials and Poke API key (from [poke.com/settings/advanced](https://poke.com/settings/advanced)): Edit `config.yml` with your email credentials and Poke API key (from [poke.com/kitchen](https://poke.com/kitchen) → API Keys — **not** Settings → Advanced or the Recipes page; those issue a legacy `pk_` key that only works with the deprecated `inbound-sms/webhook` endpoint, not the one below):
```yaml ```yaml
webhook_url: https://poke.com/api/v1/inbound/api-message webhook_url: https://poke.com/api/v1/inbound/api-message
poke_api_key: your-api-key # from https://poke.com/settings/advanced poke_api_key: your-api-key # from https://poke.com/kitchen → API Keys (V2 key required for this endpoint)
accounts: accounts:
# iCloud Mail — login is @icloud.com, send as your custom domain # iCloud Mail — login is @icloud.com, send as your custom domain
+105 -34
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
__version__ = "0.1.0" __version__ = "0.1.0"
import asyncio import asyncio
import hmac
import logging import logging
import os import os
import smtplib import smtplib
@@ -13,16 +13,13 @@ from email import policy
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.parser import BytesParser from email.parser import BytesParser
from typing import Optional
import hmac
import httpx import httpx
import uvicorn import uvicorn
import yaml import yaml
from fastmcp import Context, FastMCP
from fastmcp.server.auth import AccessToken, TokenVerifier
from imapclient import IMAPClient from imapclient import IMAPClient
from fastmcp import FastMCP, Context
from fastmcp.server.auth import TokenVerifier, AccessToken
from starlette.middleware import Middleware from starlette.middleware import Middleware
from starlette.responses import JSONResponse, Response from starlette.responses import JSONResponse, Response
from starlette.types import ASGIApp, Receive, Scope, Send from starlette.types import ASGIApp, Receive, Scope, Send
@@ -225,6 +222,12 @@ def parse_accounts(config: dict) -> list[dict]:
def resolve_account(accounts: list[dict], account_id: str) -> dict: def resolve_account(accounts: list[dict], account_id: str) -> dict:
# No ambiguity possible with a single configured account — use it
# even if the caller passed an account_id that doesn't match (e.g. a
# hallucinated or stale id), rather than hard-failing.
if len(accounts) == 1:
return accounts[0]
if not account_id or not account_id.strip(): if not account_id or not account_id.strip():
raise ValueError( raise ValueError(
f"account_id is required. Available accounts: {[a['id'] for a in accounts]}. " f"account_id is required. Available accounts: {[a['id'] for a in accounts]}. "
@@ -359,11 +362,11 @@ def parse_email_message(raw: bytes) -> dict:
def build_search_criteria( def build_search_criteria(
from_addr: Optional[str] = None, from_addr: str | None = None,
to_addr: Optional[str] = None, to_addr: str | None = None,
subject: Optional[str] = None, subject: str | None = None,
since: Optional[str] = None, since: str | None = None,
before: Optional[str] = None, before: str | None = None,
) -> list: ) -> list:
criteria = [] criteria = []
if from_addr: if from_addr:
@@ -402,15 +405,53 @@ def detect_drafts_folder(client: IMAPClient) -> str:
return "Drafts" return "Drafts"
def detect_sent_folder(client: IMAPClient) -> str:
folders = client.list_folders()
for flags, _delim, name in folders:
if b"\\Sent" in flags:
return name
for name in ("Sent", "Sent Messages", "Sent Items", "[Gmail]/Sent Mail", "INBOX.Sent"):
if client.folder_exists(name):
return name
return "Sent"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Poke webhook # Poke webhook
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_BODY_PREVIEW_LIMIT = 2000
def _build_poke_message(email_data: dict, account: dict) -> str:
"""Build the natural-language 'message' Poke's api-message endpoint expects.
Every documented usage example for /api/v1/inbound/api-message wraps its
payload in a top-level 'message' string — the endpoint accepts "any JSON
object", but without 'message' there's nothing for Poke's agent to treat
as an actionable instruction, so it can ingest the payload as inert
context without ever surfacing a heads-up.
"""
preview = (email_data.get("body_text") or "").strip()
if not preview and email_data.get("body_html"):
preview = "(HTML-only email — see body_html for content)"
if len(preview) > _BODY_PREVIEW_LIMIT:
preview = preview[:_BODY_PREVIEW_LIMIT] + "... (truncated, see body_text for full content)"
return (
f"New email received on {account['from_address']}\n"
f"From: {email_data['from']}\n"
f"Subject: {email_data['subject'] or '(no subject)'}\n\n"
f"{preview}"
)
async def forward_to_poke( async def forward_to_poke(
email_data: dict, account: dict, webhook_url: str, api_key: str email_data: dict, account: dict, webhook_url: str, api_key: str
) -> bool: ) -> bool:
payload = { payload = {
"message": _build_poke_message(email_data, account),
"account_id": account["id"], "account_id": account["id"],
"from_address": account["from_address"], "from_address": account["from_address"],
"from": email_data["from"], "from": email_data["from"],
@@ -442,6 +483,14 @@ async def forward_to_poke(
async with httpx.AsyncClient(timeout=30) as http: async with httpx.AsyncClient(timeout=30) as http:
resp = await http.post(webhook_url, json=payload, headers=headers) resp = await http.post(webhook_url, json=payload, headers=headers)
resp.raise_for_status() resp.raise_for_status()
# A 2xx status doesn't guarantee delivery — Poke can return
# HTTP 200 with {"success": false, ...} on a soft failure, so
# the status code alone isn't sufficient confirmation.
result = resp.json()
if not result.get("success", True):
raise RuntimeError(
f"Poke reported failure: {result.get('message', 'unknown error')}"
)
logger.info( logger.info(
"Forwarded email '%s' to Poke (status %d)", "Forwarded email '%s' to Poke (status %d)",
email_data["subject"], email_data["subject"],
@@ -479,10 +528,14 @@ async def _forward_uid_batch(
len(uids), len(uids),
_format_uid_list(uids), _format_uid_list(uids),
) )
raw_messages = await asyncio.to_thread(client.fetch, uids, ["RFC822"]) # BODY.PEEK[] (not RFC822/BODY[]) so the fetch never implicitly sets
# \Seen — some servers (e.g. iCloud) apply \Seen on a plain RFC822
# fetch even when the folder was SELECTed read-only, which then queues
# an unsolicited FETCH FLAGS response that breaks the next IDLE call.
raw_messages = await asyncio.to_thread(client.fetch, uids, ["BODY.PEEK[]"])
for uid in uids: for uid in uids:
data = raw_messages.get(uid, {}) data = raw_messages.get(uid, {})
raw = data.get(b"RFC822", b"") raw = data.get(b"BODY[]", b"")
if not raw: if not raw:
logger.debug( logger.debug(
"[%s/%s] Skipping UID %s because RFC822 payload was empty", "[%s/%s] Skipping UID %s because RFC822 payload was empty",
@@ -524,8 +577,8 @@ async def watch_folder(
# silently drop mail that arrived during the reconnect window. Reset only # silently drop mail that arrived during the reconnect window. Reset only
# on first run or when the server's UIDVALIDITY changes (which means UIDs # on first run or when the server's UIDVALIDITY changes (which means UIDs
# have been reassigned and the previous cursor is meaningless). # have been reassigned and the previous cursor is meaningless).
last_seen_uid: Optional[int] = None last_seen_uid: int | None = None
last_uidvalidity: Optional[int] = None last_uidvalidity: int | None = None
while not stop_event.is_set(): while not stop_event.is_set():
client = None client = None
@@ -732,9 +785,7 @@ async def _poll_folder(
mailbox_uids[-1] if mailbox_uids else last_seen_uid, mailbox_uids[-1] if mailbox_uids else last_seen_uid,
) )
except Exception as e: except Exception as e:
logger.warning( logger.warning("[%s/%s] Poll error: %s", account["id"], folder, e)
"[%s/%s] Poll error: %s", account["id"], folder, e
)
raise # reconnect via outer loop raise # reconnect via outer loop
await asyncio.sleep(60) await asyncio.sleep(60)
@@ -832,11 +883,11 @@ async def search_emails(
ctx: Context, ctx: Context,
account_id: str, account_id: str,
folder: str = "INBOX", folder: str = "INBOX",
from_addr: Optional[str] = None, from_addr: str | None = None,
to_addr: Optional[str] = None, to_addr: str | None = None,
subject: Optional[str] = None, subject: str | None = None,
since: Optional[str] = None, since: str | None = None,
before: Optional[str] = None, before: str | None = None,
limit: int = 20, limit: int = 20,
) -> list[dict]: ) -> list[dict]:
accounts = ctx.lifespan_context["accounts"] accounts = ctx.lifespan_context["accounts"]
@@ -912,10 +963,10 @@ async def read_email(
client = get_imap_client(acc) client = get_imap_client(acc)
try: try:
client.select_folder(folder, readonly=True) client.select_folder(folder, readonly=True)
data = client.fetch([uid], ["RFC822"]) data = client.fetch([uid], ["BODY.PEEK[]"])
if uid not in data: if uid not in data:
return {"error": f"Email UID {uid} not found in {folder}"} return {"error": f"Email UID {uid} not found in {folder}"}
return parse_email_message(data[uid][b"RFC822"]) return parse_email_message(data[uid][b"BODY[]"])
finally: finally:
client.logout() client.logout()
@@ -931,11 +982,11 @@ async def send_email(
to: str, to: str,
subject: str, subject: str,
body: str, body: str,
cc: Optional[str] = None, cc: str | None = None,
bcc: Optional[str] = None, bcc: str | None = None,
html: Optional[str] = None, html: str | None = None,
reply_to_uid: Optional[int] = None, reply_to_uid: int | None = None,
reply_to_folder: Optional[str] = None, reply_to_folder: str | None = None,
) -> dict: ) -> dict:
accounts = ctx.lifespan_context["accounts"] accounts = ctx.lifespan_context["accounts"]
acc = resolve_account(accounts, account_id) acc = resolve_account(accounts, account_id)
@@ -999,6 +1050,26 @@ async def send_email(
smtp.login(acc["smtp_username"], acc["smtp_password"]) smtp.login(acc["smtp_username"], acc["smtp_password"])
smtp.sendmail(acc["smtp_username"], recipients, msg.as_string()) smtp.sendmail(acc["smtp_username"], recipients, msg.as_string())
# SMTP relay doesn't copy the message to Sent the way a provider's
# own webmail/Mail app does — that's a client-side step over IMAP,
# so we have to do it ourselves or the message is never visible
# anywhere in the account after sending.
try:
imap = get_imap_client(acc)
try:
sent_folder = detect_sent_folder(imap)
if not imap.folder_exists(sent_folder):
imap.create_folder(sent_folder)
imap.append(sent_folder, msg.as_bytes(), flags=[b"\\Seen"])
finally:
imap.logout()
except Exception as e:
logger.warning(
"Sent via SMTP but failed to save copy to Sent folder for '%s': %s",
acc["id"],
e,
)
return {"success": True, "message_id": msg.get("Message-ID", "")} return {"success": True, "message_id": msg.get("Message-ID", "")}
return await asyncio.to_thread(_send) return await asyncio.to_thread(_send)
@@ -1013,9 +1084,9 @@ async def create_draft(
to: str, to: str,
subject: str, subject: str,
body: str, body: str,
cc: Optional[str] = None, cc: str | None = None,
bcc: Optional[str] = None, bcc: str | None = None,
html: Optional[str] = None, html: str | None = None,
) -> dict: ) -> dict:
accounts = ctx.lifespan_context["accounts"] accounts = ctx.lifespan_context["accounts"]
acc = resolve_account(accounts, account_id) acc = resolve_account(accounts, account_id)
@@ -1310,7 +1381,7 @@ async def get_server_info(ctx: Context) -> dict:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if __name__ == "__main__": if __name__ == "__main__":
port = int(os.environ.get("PORT", 3000)) port = int(os.environ.get("PORT", "3000"))
host = "0.0.0.0" host = "0.0.0.0"
logger.info("Starting poke-mail on %s:%d", host, port) logger.info("Starting poke-mail on %s:%d", host, port)
app = mcp.http_app( app = mcp.http_app(
+3 -1
View File
@@ -183,7 +183,9 @@ PYEOF
echo "" echo ""
else else
echo " ⚠ poke_api_key not set in config.yml." echo " ⚠ poke_api_key not set in config.yml."
echo " Run 'npx poke login' then restart, or paste your key from poke.com/settings/advanced" echo " Run 'npx poke login' then restart, or paste a V2 key from poke.com/kitchen -> API Keys"
echo " (Settings > Advanced and the Recipes page issue a different, incompatible key —"
echo " the webhook_url above requires a V2 key created in Kitchen)"
echo "" echo ""
printf " Poke API key (leave blank to set manually later): " printf " Poke API key (leave blank to set manually later): "
read -r POKE_TOKEN_INPUT read -r POKE_TOKEN_INPUT