Compare commits
19
Commits
a2b859dd89
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64d795173c | ||
|
|
3e816beb6e | ||
|
|
9f26098954 | ||
|
|
35e4d3da86 | ||
|
|
6d1d274457 | ||
|
|
b3e44d8ffb | ||
|
|
a04bd8dedb | ||
|
|
eee315fe3d | ||
|
|
604fb38937 | ||
|
|
93e3de3e84 | ||
|
|
2b45db5f5e | ||
|
|
23d4b1fc79 | ||
|
|
9824c5d6bf | ||
|
|
329df8afc3 | ||
|
|
c88ea40459 | ||
|
|
68c40055e7 | ||
|
|
83a3c482ec | ||
|
|
af796d8583 | ||
|
|
3d4a1cb791 |
@@ -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"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Kacper Kwapisz
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -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
|
||||||
|
|||||||
+301
-62
@@ -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]}. "
|
||||||
@@ -265,6 +268,15 @@ def resolve_account(accounts: list[dict], account_id: str) -> dict:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# RFC 2971 IMAP ID — required by some providers (e.g. netease 163.com / 126.com / yeah.net)
|
||||||
|
# which reject clients that don't identify themselves after login.
|
||||||
|
_IMAP_CLIENT_ID = {
|
||||||
|
"name": "poke-mail",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"vendor": "Poke Interactions",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_imap_client(account: dict) -> IMAPClient:
|
def get_imap_client(account: dict) -> IMAPClient:
|
||||||
port = account["imap_port"]
|
port = account["imap_port"]
|
||||||
use_ssl = port == 993
|
use_ssl = port == 993
|
||||||
@@ -272,6 +284,13 @@ def get_imap_client(account: dict) -> IMAPClient:
|
|||||||
if not use_ssl:
|
if not use_ssl:
|
||||||
client.starttls()
|
client.starttls()
|
||||||
client.login(account["imap_username"], account["imap_password"])
|
client.login(account["imap_username"], account["imap_password"])
|
||||||
|
# Send IMAP ID if the server supports it. Non-fatal: not all servers do,
|
||||||
|
# and we never want this to break an otherwise-working login.
|
||||||
|
try:
|
||||||
|
if client.has_capability("ID"):
|
||||||
|
client.id_(_IMAP_CLIENT_ID)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("IMAP ID command failed for %s: %s", account.get("id"), e)
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
@@ -343,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:
|
||||||
@@ -386,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"],
|
||||||
@@ -426,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"],
|
||||||
@@ -439,6 +504,59 @@ async def forward_to_poke(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _format_uid_list(uids: list[int], limit: int = 10) -> str:
|
||||||
|
if not uids:
|
||||||
|
return "[]"
|
||||||
|
shown = ", ".join(str(uid) for uid in uids[:limit])
|
||||||
|
if len(uids) > limit:
|
||||||
|
shown += f", ... (+{len(uids) - limit} more)"
|
||||||
|
return f"[{shown}]"
|
||||||
|
|
||||||
|
|
||||||
|
async def _forward_uid_batch(
|
||||||
|
client: IMAPClient,
|
||||||
|
account: dict,
|
||||||
|
folder: str,
|
||||||
|
webhook_url: str,
|
||||||
|
api_key: str,
|
||||||
|
uids: list[int],
|
||||||
|
) -> None:
|
||||||
|
logger.info(
|
||||||
|
"[%s/%s] Forwarding %d message(s) for UIDs %s",
|
||||||
|
account["id"],
|
||||||
|
folder,
|
||||||
|
len(uids),
|
||||||
|
_format_uid_list(uids),
|
||||||
|
)
|
||||||
|
# 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"BODY[]", b"")
|
||||||
|
if not raw:
|
||||||
|
logger.debug(
|
||||||
|
"[%s/%s] Skipping UID %s because RFC822 payload was empty",
|
||||||
|
account["id"],
|
||||||
|
folder,
|
||||||
|
uid,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
email_data = parse_email_message(raw)
|
||||||
|
await forward_to_poke(email_data, account, webhook_url, api_key)
|
||||||
|
|
||||||
|
if account.get("mark_as_read", False):
|
||||||
|
await asyncio.to_thread(client.set_flags, uids, [b"\\Seen"])
|
||||||
|
logger.debug(
|
||||||
|
"[%s/%s] Marked UIDs %s as Seen",
|
||||||
|
account["id"],
|
||||||
|
folder,
|
||||||
|
_format_uid_list(uids),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# IDLE watcher
|
# IDLE watcher
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -454,16 +572,26 @@ async def watch_folder(
|
|||||||
backoff = 5
|
backoff = 5
|
||||||
max_backoff = 60
|
max_backoff = 60
|
||||||
|
|
||||||
|
# Hoisted across reconnects so a transient disconnect (e.g. iCloud's
|
||||||
|
# unsolicited FETCH FLAGS during IDLE) doesn't re-baseline the cursor and
|
||||||
|
# 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: int | None = None
|
||||||
|
last_uidvalidity: int | None = None
|
||||||
|
|
||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
client = None
|
client = None
|
||||||
try:
|
try:
|
||||||
client = await asyncio.to_thread(get_imap_client, account)
|
client = await asyncio.to_thread(get_imap_client, account)
|
||||||
await asyncio.to_thread(
|
select_info = await asyncio.to_thread(
|
||||||
client.select_folder,
|
client.select_folder,
|
||||||
folder,
|
folder,
|
||||||
readonly=not account.get("mark_as_read", False),
|
readonly=not account.get("mark_as_read", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
uidvalidity = select_info.get(b"UIDVALIDITY") if select_info else None
|
||||||
|
|
||||||
# Check IDLE support
|
# Check IDLE support
|
||||||
if not client.has_capability("IDLE"):
|
if not client.has_capability("IDLE"):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -471,18 +599,56 @@ async def watch_folder(
|
|||||||
account["id"],
|
account["id"],
|
||||||
folder,
|
folder,
|
||||||
)
|
)
|
||||||
|
# Mutable cursor state — _poll_folder updates it so reconnects
|
||||||
|
# don't re-baseline.
|
||||||
|
cursor = {
|
||||||
|
"last_seen_uid": last_seen_uid,
|
||||||
|
"last_uidvalidity": last_uidvalidity,
|
||||||
|
}
|
||||||
|
try:
|
||||||
await _poll_folder(
|
await _poll_folder(
|
||||||
client, account, folder, webhook_url, api_key, stop_event
|
client,
|
||||||
|
account,
|
||||||
|
folder,
|
||||||
|
webhook_url,
|
||||||
|
api_key,
|
||||||
|
stop_event,
|
||||||
|
cursor,
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
last_seen_uid = cursor["last_seen_uid"]
|
||||||
|
last_uidvalidity = cursor["last_uidvalidity"]
|
||||||
|
# _poll_folder only returns when stop_event is set; on errors it
|
||||||
|
# raises and we fall through to the outer reconnect path.
|
||||||
return
|
return
|
||||||
|
|
||||||
# Record existing unseen UIDs so we only forward truly new ones
|
if last_seen_uid is None or uidvalidity != last_uidvalidity:
|
||||||
existing_unseen = set(await asyncio.to_thread(client.search, ["UNSEEN"]))
|
if last_uidvalidity is not None and uidvalidity != last_uidvalidity:
|
||||||
logger.info(
|
logger.warning(
|
||||||
"[%s/%s] Watching for new emails via IDLE (%d existing unseen skipped)",
|
"[%s/%s] UIDVALIDITY changed (%s → %s) — resetting cursor",
|
||||||
account["id"],
|
account["id"],
|
||||||
folder,
|
folder,
|
||||||
len(existing_unseen),
|
last_uidvalidity,
|
||||||
|
uidvalidity,
|
||||||
|
)
|
||||||
|
mailbox_uids = await asyncio.to_thread(client.search, ["ALL"])
|
||||||
|
last_seen_uid = mailbox_uids[-1] if mailbox_uids else 0
|
||||||
|
last_uidvalidity = uidvalidity
|
||||||
|
logger.info(
|
||||||
|
"[%s/%s] Watching for new emails via IDLE from UID %d (%d existing message(s), mark_as_read=%s)",
|
||||||
|
account["id"],
|
||||||
|
folder,
|
||||||
|
last_seen_uid,
|
||||||
|
len(mailbox_uids),
|
||||||
|
account.get("mark_as_read", False),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"[%s/%s] Resumed IDLE watch from UID %d (mark_as_read=%s)",
|
||||||
|
account["id"],
|
||||||
|
folder,
|
||||||
|
last_seen_uid,
|
||||||
|
account.get("mark_as_read", False),
|
||||||
)
|
)
|
||||||
backoff = 5
|
backoff = 5
|
||||||
|
|
||||||
@@ -502,25 +668,36 @@ async def watch_folder(
|
|||||||
"[%s/%s] IDLE responses: %s", account["id"], folder, responses
|
"[%s/%s] IDLE responses: %s", account["id"], folder, responses
|
||||||
)
|
)
|
||||||
|
|
||||||
uids = await asyncio.to_thread(client.search, ["UNSEEN"])
|
mailbox_uids = await asyncio.to_thread(client.search, ["ALL"])
|
||||||
# Only forward emails that arrived after we started watching
|
new_uids = [uid for uid in mailbox_uids if uid > last_seen_uid]
|
||||||
new_uids = [u for u in uids if u not in existing_unseen]
|
|
||||||
if not new_uids:
|
if not new_uids:
|
||||||
continue
|
logger.debug(
|
||||||
|
"[%s/%s] No new UIDs above %d (mailbox latest UID %d)",
|
||||||
raw_messages = await asyncio.to_thread(
|
account["id"],
|
||||||
client.fetch, new_uids, ["RFC822"]
|
folder,
|
||||||
|
last_seen_uid,
|
||||||
|
mailbox_uids[-1] if mailbox_uids else last_seen_uid,
|
||||||
)
|
)
|
||||||
for uid, data in raw_messages.items():
|
|
||||||
raw = data.get(b"RFC822", b"")
|
|
||||||
if not raw:
|
|
||||||
continue
|
continue
|
||||||
email_data = parse_email_message(raw)
|
|
||||||
await forward_to_poke(email_data, account, webhook_url, api_key)
|
|
||||||
|
|
||||||
if account.get("mark_as_read", False):
|
logger.info(
|
||||||
await asyncio.to_thread(client.set_flags, new_uids, [b"\\Seen"])
|
"[%s/%s] Found %d new UID(s) above %d: %s",
|
||||||
existing_unseen.update(new_uids)
|
account["id"],
|
||||||
|
folder,
|
||||||
|
len(new_uids),
|
||||||
|
last_seen_uid,
|
||||||
|
_format_uid_list(new_uids),
|
||||||
|
)
|
||||||
|
await _forward_uid_batch(
|
||||||
|
client, account, folder, webhook_url, api_key, new_uids
|
||||||
|
)
|
||||||
|
last_seen_uid = new_uids[-1]
|
||||||
|
logger.debug(
|
||||||
|
"[%s/%s] Advanced UID cursor to %d",
|
||||||
|
account["id"],
|
||||||
|
folder,
|
||||||
|
last_seen_uid,
|
||||||
|
)
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
@@ -549,22 +726,64 @@ async def _poll_folder(
|
|||||||
webhook_url: str,
|
webhook_url: str,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
stop_event: asyncio.Event,
|
stop_event: asyncio.Event,
|
||||||
|
cursor: dict,
|
||||||
):
|
):
|
||||||
"""Fallback polling for servers without IDLE support. Checks every 60 seconds."""
|
"""Fallback polling for servers without IDLE support. Checks every 60 seconds.
|
||||||
logger.info("[%s/%s] Polling for new emails every 60s", account["id"], folder)
|
|
||||||
|
`cursor` is a mutable dict with keys 'last_seen_uid' and 'last_uidvalidity'
|
||||||
|
shared with watch_folder so cursor state survives reconnects.
|
||||||
|
"""
|
||||||
|
if cursor.get("last_seen_uid") is None:
|
||||||
|
mailbox_uids = await asyncio.to_thread(client.search, ["ALL"])
|
||||||
|
cursor["last_seen_uid"] = mailbox_uids[-1] if mailbox_uids else 0
|
||||||
|
logger.info(
|
||||||
|
"[%s/%s] Polling for new emails every 60s from UID %d (%d existing message(s), mark_as_read=%s)",
|
||||||
|
account["id"],
|
||||||
|
folder,
|
||||||
|
cursor["last_seen_uid"],
|
||||||
|
len(mailbox_uids),
|
||||||
|
account.get("mark_as_read", False),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"[%s/%s] Resumed polling from UID %d (mark_as_read=%s)",
|
||||||
|
account["id"],
|
||||||
|
folder,
|
||||||
|
cursor["last_seen_uid"],
|
||||||
|
account.get("mark_as_read", False),
|
||||||
|
)
|
||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
uids = await asyncio.to_thread(client.search, ["UNSEEN"])
|
last_seen_uid = cursor["last_seen_uid"]
|
||||||
if uids:
|
mailbox_uids = await asyncio.to_thread(client.search, ["ALL"])
|
||||||
raw_messages = await asyncio.to_thread(client.fetch, uids, ["RFC822"])
|
new_uids = [uid for uid in mailbox_uids if uid > last_seen_uid]
|
||||||
for uid, data in raw_messages.items():
|
if new_uids:
|
||||||
raw = data.get(b"RFC822", b"")
|
logger.info(
|
||||||
if not raw:
|
"[%s/%s] Found %d new UID(s) above %d: %s",
|
||||||
continue
|
account["id"],
|
||||||
email_data = parse_email_message(raw)
|
folder,
|
||||||
await forward_to_poke(email_data, account, webhook_url, api_key)
|
len(new_uids),
|
||||||
if account.get("mark_as_read", False):
|
last_seen_uid,
|
||||||
await asyncio.to_thread(client.set_flags, uids, [b"\\Seen"])
|
_format_uid_list(new_uids),
|
||||||
|
)
|
||||||
|
await _forward_uid_batch(
|
||||||
|
client, account, folder, webhook_url, api_key, new_uids
|
||||||
|
)
|
||||||
|
cursor["last_seen_uid"] = new_uids[-1]
|
||||||
|
logger.debug(
|
||||||
|
"[%s/%s] Advanced UID cursor to %d",
|
||||||
|
account["id"],
|
||||||
|
folder,
|
||||||
|
cursor["last_seen_uid"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"[%s/%s] No new UIDs above %d (mailbox latest UID %d)",
|
||||||
|
account["id"],
|
||||||
|
folder,
|
||||||
|
last_seen_uid,
|
||||||
|
mailbox_uids[-1] if mailbox_uids else last_seen_uid,
|
||||||
|
)
|
||||||
except Exception as e:
|
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
|
raise # reconnect via outer loop
|
||||||
@@ -664,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"]
|
||||||
@@ -744,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()
|
||||||
|
|
||||||
@@ -763,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)
|
||||||
@@ -831,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)
|
||||||
@@ -845,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)
|
||||||
@@ -1142,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
|
||||||
@@ -207,10 +209,23 @@ PYEOF
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 4. MCP_API_KEY — generate once and persist to .env
|
# ── Load .env early so POKE_TUNNEL is visible to the MCP_API_KEY logic below ──
|
||||||
if [ ! -f .env ] \
|
if [ -f .env ]; then
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Tunnel-mode detection must happen BEFORE the MCP_API_KEY block: in tunnel
|
||||||
|
# mode (POKE_TUNNEL=1, the default) the local server runs unauthenticated and
|
||||||
|
# the tunnel handles auth, so we must NOT generate a key — doing so previously
|
||||||
|
# overwrote user-supplied keys and broke working setups (issue #9).
|
||||||
|
POKE_TUNNEL="${POKE_TUNNEL:-1}"
|
||||||
|
|
||||||
|
# 4. MCP_API_KEY — generate once and persist to .env (skipped in tunnel mode)
|
||||||
|
if [ "${POKE_TUNNEL}" != "1" ] && { [ ! -f .env ] \
|
||||||
|| grep -Eq '^[[:space:]]*MCP_API_KEY=your-secret-key-here' .env 2>/dev/null \
|
|| grep -Eq '^[[:space:]]*MCP_API_KEY=your-secret-key-here' .env 2>/dev/null \
|
||||||
|| ! grep -Eq '^[[:space:]]*MCP_API_KEY=.+' .env 2>/dev/null; then
|
|| ! grep -Eq '^[[:space:]]*MCP_API_KEY=.+' .env 2>/dev/null; }; then
|
||||||
RANDOM_KEY=$(python3 -c "
|
RANDOM_KEY=$(python3 -c "
|
||||||
import secrets, string
|
import secrets, string
|
||||||
alphabet = string.ascii_letters + string.digits
|
alphabet = string.ascii_letters + string.digits
|
||||||
@@ -233,18 +248,13 @@ PYEOF
|
|||||||
fi
|
fi
|
||||||
echo " ✓ MCP_API_KEY generated and saved to .env"
|
echo " ✓ MCP_API_KEY generated and saved to .env"
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
# Re-source so the freshly generated key is visible to the rest of the script
|
||||||
|
|
||||||
# ── Load .env ─────────────────────────────────────────────────────────────────
|
|
||||||
if [ -f .env ]; then
|
|
||||||
set -a
|
set -a
|
||||||
source .env
|
source .env
|
||||||
set +a
|
set +a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Tunnel-mode detection ─────────────────────────────────────────────────────
|
# ── Tunnel-mode enforcement ───────────────────────────────────────────────────
|
||||||
POKE_TUNNEL="${POKE_TUNNEL:-1}"
|
|
||||||
|
|
||||||
if [ "${POKE_TUNNEL}" != "1" ]; then
|
if [ "${POKE_TUNNEL}" != "1" ]; then
|
||||||
: "${MCP_API_KEY:?MCP_API_KEY is not set — add it to .env or export it}"
|
: "${MCP_API_KEY:?MCP_API_KEY is not set — add it to .env or export it}"
|
||||||
else
|
else
|
||||||
|
|||||||
Reference in New Issue
Block a user