Compare commits
8
Commits
604fb38937
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64d795173c | ||
|
|
3e816beb6e | ||
|
|
9f26098954 | ||
|
|
35e4d3da86 | ||
|
|
6d1d274457 | ||
|
|
b3e44d8ffb | ||
|
|
a04bd8dedb | ||
|
|
eee315fe3d |
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
target-version = "py310"
|
||||
|
||||
[lint]
|
||||
# Blind excepts and silent cleanup in mail parsing are intentional here.
|
||||
ignore = ["BLE001", "S110"]
|
||||
@@ -61,11 +61,11 @@ Set up poke-mail (https://github.com/kacperkwapisz/poke-mail) for me — clone t
|
||||
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
|
||||
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:
|
||||
# iCloud Mail — login is @icloud.com, send as your custom domain
|
||||
|
||||
+105
-34
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
__version__ = "0.1.0"
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import smtplib
|
||||
@@ -13,16 +13,13 @@ from email import policy
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.parser import BytesParser
|
||||
from typing import Optional
|
||||
|
||||
import hmac
|
||||
|
||||
import httpx
|
||||
import uvicorn
|
||||
import yaml
|
||||
from fastmcp import Context, FastMCP
|
||||
from fastmcp.server.auth import AccessToken, TokenVerifier
|
||||
from imapclient import IMAPClient
|
||||
from fastmcp import FastMCP, Context
|
||||
from fastmcp.server.auth import TokenVerifier, AccessToken
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.responses import JSONResponse, Response
|
||||
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:
|
||||
# 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():
|
||||
raise ValueError(
|
||||
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(
|
||||
from_addr: Optional[str] = None,
|
||||
to_addr: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
since: Optional[str] = None,
|
||||
before: Optional[str] = None,
|
||||
from_addr: str | None = None,
|
||||
to_addr: str | None = None,
|
||||
subject: str | None = None,
|
||||
since: str | None = None,
|
||||
before: str | None = None,
|
||||
) -> list:
|
||||
criteria = []
|
||||
if from_addr:
|
||||
@@ -402,15 +405,53 @@ def detect_drafts_folder(client: IMAPClient) -> str:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_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(
|
||||
email_data: dict, account: dict, webhook_url: str, api_key: str
|
||||
) -> bool:
|
||||
payload = {
|
||||
"message": _build_poke_message(email_data, account),
|
||||
"account_id": account["id"],
|
||||
"from_address": account["from_address"],
|
||||
"from": email_data["from"],
|
||||
@@ -442,6 +483,14 @@ async def forward_to_poke(
|
||||
async with httpx.AsyncClient(timeout=30) as http:
|
||||
resp = await http.post(webhook_url, json=payload, headers=headers)
|
||||
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(
|
||||
"Forwarded email '%s' to Poke (status %d)",
|
||||
email_data["subject"],
|
||||
@@ -479,10 +528,14 @@ async def _forward_uid_batch(
|
||||
len(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:
|
||||
data = raw_messages.get(uid, {})
|
||||
raw = data.get(b"RFC822", b"")
|
||||
raw = data.get(b"BODY[]", b"")
|
||||
if not raw:
|
||||
logger.debug(
|
||||
"[%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
|
||||
# on first run or when the server's UIDVALIDITY changes (which means UIDs
|
||||
# have been reassigned and the previous cursor is meaningless).
|
||||
last_seen_uid: Optional[int] = None
|
||||
last_uidvalidity: Optional[int] = None
|
||||
last_seen_uid: int | None = None
|
||||
last_uidvalidity: int | None = None
|
||||
|
||||
while not stop_event.is_set():
|
||||
client = None
|
||||
@@ -732,9 +785,7 @@ async def _poll_folder(
|
||||
mailbox_uids[-1] if mailbox_uids else last_seen_uid,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[%s/%s] Poll error: %s", account["id"], folder, e
|
||||
)
|
||||
logger.warning("[%s/%s] Poll error: %s", account["id"], folder, e)
|
||||
raise # reconnect via outer loop
|
||||
await asyncio.sleep(60)
|
||||
|
||||
@@ -832,11 +883,11 @@ async def search_emails(
|
||||
ctx: Context,
|
||||
account_id: str,
|
||||
folder: str = "INBOX",
|
||||
from_addr: Optional[str] = None,
|
||||
to_addr: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
since: Optional[str] = None,
|
||||
before: Optional[str] = None,
|
||||
from_addr: str | None = None,
|
||||
to_addr: str | None = None,
|
||||
subject: str | None = None,
|
||||
since: str | None = None,
|
||||
before: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> list[dict]:
|
||||
accounts = ctx.lifespan_context["accounts"]
|
||||
@@ -912,10 +963,10 @@ async def read_email(
|
||||
client = get_imap_client(acc)
|
||||
try:
|
||||
client.select_folder(folder, readonly=True)
|
||||
data = client.fetch([uid], ["RFC822"])
|
||||
data = client.fetch([uid], ["BODY.PEEK[]"])
|
||||
if uid not in data:
|
||||
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:
|
||||
client.logout()
|
||||
|
||||
@@ -931,11 +982,11 @@ async def send_email(
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
cc: Optional[str] = None,
|
||||
bcc: Optional[str] = None,
|
||||
html: Optional[str] = None,
|
||||
reply_to_uid: Optional[int] = None,
|
||||
reply_to_folder: Optional[str] = None,
|
||||
cc: str | None = None,
|
||||
bcc: str | None = None,
|
||||
html: str | None = None,
|
||||
reply_to_uid: int | None = None,
|
||||
reply_to_folder: str | None = None,
|
||||
) -> dict:
|
||||
accounts = ctx.lifespan_context["accounts"]
|
||||
acc = resolve_account(accounts, account_id)
|
||||
@@ -999,6 +1050,26 @@ async def send_email(
|
||||
smtp.login(acc["smtp_username"], acc["smtp_password"])
|
||||
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 await asyncio.to_thread(_send)
|
||||
@@ -1013,9 +1084,9 @@ async def create_draft(
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
cc: Optional[str] = None,
|
||||
bcc: Optional[str] = None,
|
||||
html: Optional[str] = None,
|
||||
cc: str | None = None,
|
||||
bcc: str | None = None,
|
||||
html: str | None = None,
|
||||
) -> dict:
|
||||
accounts = ctx.lifespan_context["accounts"]
|
||||
acc = resolve_account(accounts, account_id)
|
||||
@@ -1310,7 +1381,7 @@ async def get_server_info(ctx: Context) -> dict:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.environ.get("PORT", 3000))
|
||||
port = int(os.environ.get("PORT", "3000"))
|
||||
host = "0.0.0.0"
|
||||
logger.info("Starting poke-mail on %s:%d", host, port)
|
||||
app = mcp.http_app(
|
||||
|
||||
@@ -183,7 +183,9 @@ PYEOF
|
||||
echo ""
|
||||
else
|
||||
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 ""
|
||||
printf " Poke API key (leave blank to set manually later): "
|
||||
read -r POKE_TOKEN_INPUT
|
||||
|
||||
Reference in New Issue
Block a user