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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
+301
-62
@@ -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]}. "
|
||||
@@ -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:
|
||||
port = account["imap_port"]
|
||||
use_ssl = port == 993
|
||||
@@ -272,6 +284,13 @@ def get_imap_client(account: dict) -> IMAPClient:
|
||||
if not use_ssl:
|
||||
client.starttls()
|
||||
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
|
||||
|
||||
|
||||
@@ -343,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:
|
||||
@@ -386,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"],
|
||||
@@ -426,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"],
|
||||
@@ -439,6 +504,59 @@ async def forward_to_poke(
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -454,16 +572,26 @@ async def watch_folder(
|
||||
backoff = 5
|
||||
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():
|
||||
client = None
|
||||
try:
|
||||
client = await asyncio.to_thread(get_imap_client, account)
|
||||
await asyncio.to_thread(
|
||||
select_info = await asyncio.to_thread(
|
||||
client.select_folder,
|
||||
folder,
|
||||
readonly=not account.get("mark_as_read", False),
|
||||
)
|
||||
|
||||
uidvalidity = select_info.get(b"UIDVALIDITY") if select_info else None
|
||||
|
||||
# Check IDLE support
|
||||
if not client.has_capability("IDLE"):
|
||||
logger.warning(
|
||||
@@ -471,18 +599,56 @@ async def watch_folder(
|
||||
account["id"],
|
||||
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(
|
||||
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
|
||||
|
||||
# Record existing unseen UIDs so we only forward truly new ones
|
||||
existing_unseen = set(await asyncio.to_thread(client.search, ["UNSEEN"]))
|
||||
logger.info(
|
||||
"[%s/%s] Watching for new emails via IDLE (%d existing unseen skipped)",
|
||||
if last_seen_uid is None or uidvalidity != last_uidvalidity:
|
||||
if last_uidvalidity is not None and uidvalidity != last_uidvalidity:
|
||||
logger.warning(
|
||||
"[%s/%s] UIDVALIDITY changed (%s → %s) — resetting cursor",
|
||||
account["id"],
|
||||
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
|
||||
|
||||
@@ -502,25 +668,36 @@ async def watch_folder(
|
||||
"[%s/%s] IDLE responses: %s", account["id"], folder, responses
|
||||
)
|
||||
|
||||
uids = await asyncio.to_thread(client.search, ["UNSEEN"])
|
||||
# Only forward emails that arrived after we started watching
|
||||
new_uids = [u for u in uids if u not in existing_unseen]
|
||||
mailbox_uids = await asyncio.to_thread(client.search, ["ALL"])
|
||||
new_uids = [uid for uid in mailbox_uids if uid > last_seen_uid]
|
||||
if not new_uids:
|
||||
continue
|
||||
|
||||
raw_messages = await asyncio.to_thread(
|
||||
client.fetch, new_uids, ["RFC822"]
|
||||
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,
|
||||
)
|
||||
for uid, data in raw_messages.items():
|
||||
raw = data.get(b"RFC822", b"")
|
||||
if not raw:
|
||||
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, new_uids, [b"\\Seen"])
|
||||
existing_unseen.update(new_uids)
|
||||
logger.info(
|
||||
"[%s/%s] Found %d new UID(s) above %d: %s",
|
||||
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:
|
||||
break
|
||||
@@ -549,22 +726,64 @@ async def _poll_folder(
|
||||
webhook_url: str,
|
||||
api_key: str,
|
||||
stop_event: asyncio.Event,
|
||||
cursor: dict,
|
||||
):
|
||||
"""Fallback polling for servers without IDLE support. Checks every 60 seconds."""
|
||||
logger.info("[%s/%s] Polling for new emails every 60s", account["id"], folder)
|
||||
"""Fallback polling for servers without IDLE support. Checks every 60 seconds.
|
||||
|
||||
`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():
|
||||
try:
|
||||
uids = await asyncio.to_thread(client.search, ["UNSEEN"])
|
||||
if uids:
|
||||
raw_messages = await asyncio.to_thread(client.fetch, uids, ["RFC822"])
|
||||
for uid, data in raw_messages.items():
|
||||
raw = data.get(b"RFC822", b"")
|
||||
if not raw:
|
||||
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"])
|
||||
last_seen_uid = cursor["last_seen_uid"]
|
||||
mailbox_uids = await asyncio.to_thread(client.search, ["ALL"])
|
||||
new_uids = [uid for uid in mailbox_uids if uid > last_seen_uid]
|
||||
if new_uids:
|
||||
logger.info(
|
||||
"[%s/%s] Found %d new UID(s) above %d: %s",
|
||||
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
|
||||
)
|
||||
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:
|
||||
logger.warning("[%s/%s] Poll error: %s", account["id"], folder, e)
|
||||
raise # reconnect via outer loop
|
||||
@@ -664,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"]
|
||||
@@ -744,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()
|
||||
|
||||
@@ -763,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)
|
||||
@@ -831,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)
|
||||
@@ -845,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)
|
||||
@@ -1142,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
|
||||
@@ -207,10 +209,23 @@ PYEOF
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. MCP_API_KEY — generate once and persist to .env
|
||||
if [ ! -f .env ] \
|
||||
# ── Load .env early so POKE_TUNNEL is visible to the MCP_API_KEY logic below ──
|
||||
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=.+' .env 2>/dev/null; then
|
||||
|| ! grep -Eq '^[[:space:]]*MCP_API_KEY=.+' .env 2>/dev/null; }; then
|
||||
RANDOM_KEY=$(python3 -c "
|
||||
import secrets, string
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
@@ -233,18 +248,13 @@ PYEOF
|
||||
fi
|
||||
echo " ✓ MCP_API_KEY generated and saved to .env"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── Load .env ─────────────────────────────────────────────────────────────────
|
||||
if [ -f .env ]; then
|
||||
# Re-source so the freshly generated key is visible to the rest of the script
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
# ── Tunnel-mode detection ─────────────────────────────────────────────────────
|
||||
POKE_TUNNEL="${POKE_TUNNEL:-1}"
|
||||
|
||||
# ── Tunnel-mode enforcement ───────────────────────────────────────────────────
|
||||
if [ "${POKE_TUNNEL}" != "1" ]; then
|
||||
: "${MCP_API_KEY:?MCP_API_KEY is not set — add it to .env or export it}"
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user