Compare commits
6
Commits
a04bd8dedb
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64d795173c | ||
|
|
3e816beb6e | ||
|
|
9f26098954 | ||
|
|
35e4d3da86 | ||
|
|
6d1d274457 | ||
|
|
b3e44d8ffb |
+13
-8
@@ -2,21 +2,21 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [master]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v5
|
- uses: astral-sh/ruff-action@v3
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
args: check src/
|
||||||
- run: pip install ruff
|
- uses: astral-sh/ruff-action@v3
|
||||||
- run: ruff check src/
|
with:
|
||||||
- run: ruff format --check src/
|
args: format --check src/
|
||||||
|
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -25,7 +25,6 @@ jobs:
|
|||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
cache: pip
|
|
||||||
- run: pip install -r requirements.txt
|
- run: pip install -r requirements.txt
|
||||||
- name: Verify imports
|
- name: Verify imports
|
||||||
run: python -c "from src.server import mcp; print('OK:', mcp.name)"
|
run: python -c "from src.server import mcp; print('OK:', mcp.name)"
|
||||||
@@ -36,8 +35,14 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: docker/setup-buildx-action@v3
|
- 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
|
- uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
tags: poke-mail:test
|
tags: poke-mail:test
|
||||||
|
cache-from: type=registry,ref=git.soconnor.dev/soconnor/poke-mail:buildcache
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Build & Push Container Image
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [master]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -12,9 +12,6 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -24,8 +21,8 @@ jobs:
|
|||||||
- uses: docker/login-action@v3
|
- uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- uses: docker/metadata-action@v5
|
- uses: docker/metadata-action@v5
|
||||||
id: meta
|
id: meta
|
||||||
@@ -43,3 +40,5 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
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
|
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
|
||||||
|
|||||||
+91
-30
@@ -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
|
||||||
@@ -365,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:
|
||||||
@@ -408,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"],
|
||||||
@@ -448,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"],
|
||||||
@@ -534,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
|
||||||
@@ -742,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)
|
||||||
|
|
||||||
@@ -842,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"]
|
||||||
@@ -941,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)
|
||||||
@@ -1009,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)
|
||||||
@@ -1023,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)
|
||||||
@@ -1320,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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user