Compare commits

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 18:18:56 -04:00
soconnor 35e4d3da86 fix: make docker build/push work with Gitea
CI / lint (push) Successful in 9m22s
CI / build (push) Successful in 9m21s
Build & Push Container Image / build-and-push (push) Successful in 48s
CI / docker (push) Successful in 23s
- Replace type=gha cache (broken on Gitea: unparseable runtime token)
  with type=registry cache using image-manifest/oci-mediatypes options
  that the Gitea registry accepts.
- Push with a Gitea PAT (REGISTRY_TOKEN) since the auto job token
  cannot publish OCI images to the package repo.
- Add registry login to the CI docker smoke-test job.
2026-08-02 16:54:15 -04:00
soconnor 6d1d274457 fix: make ruff lint pass in CI
CI / lint (push) Successful in 4s
CI / build (push) Successful in 9m20s
Build & Push Container Image / build-and-push (push) Failing after 10s
CI / docker (push) Successful in 28s
Add .ruff.toml ignoring BLE001/S110 (intentional mail parsing), apply
ruff auto-fixes (imports, Optional->X|None), and fix format, shebang and
PORT env default.
2026-08-02 16:23:46 -04:00
soconnor b3e44d8ffb fix: update branch references from main to master in CI and publish workflows
CI / lint (push) Failing after 41s
CI / build (push) Successful in 9m33s
CI / docker (push) Skipped
Build & Push Container Image / build-and-push (push) Failing after 2m13s
2026-08-02 16:09:26 -04:00
soconnor a04bd8dedb feat: add CI and publish workflows for container image 2026-08-02 16:07:38 -04:00
soconnor eee315fe3d fix read-only behavior 2026-08-02 16:07:16 -04:00
0xKandGitHub 604fb38937 Merge pull request #15 from kacperkwapisz/add-mit-license
docs: add MIT license
2026-06-25 01:04:26 +02:00
Kacper Kwapisz 93e3de3e84 docs: add MIT license 2026-06-25 01:02:57 +02:00
0xKandGitHub 2b45db5f5e Merge pull request #14 from kacperkwapisz/fix/issue-5-watcher-uid-tracking
Watcher: UID-based tracking + persist cursor across reconnects
2026-04-29 11:16:28 +02:00
Kacper Kwapisz 23d4b1fc79 Merge remote-tracking branch 'origin/main' into fix/issue-5-watcher-uid-tracking 2026-04-29 11:16:20 +02:00
0xKandGitHub 9824c5d6bf Merge pull request #13 from kacperkwapisz/fix/issue-9-mcp-key-tunnel-mode
Skip MCP_API_KEY generation in tunnel mode
2026-04-29 11:16:10 +02:00
0xKandGitHub 329df8afc3 Merge pull request #12 from kacperkwapisz/fix/issue-10-imap-id-command
Send IMAP ID after login (fixes netease login rejection)
2026-04-29 11:16:06 +02:00
Kacper Kwapisz c88ea40459 Persist watcher UID cursor across reconnects
The IDLE watcher previously re-baselined last_seen_uid on every
reconnect (e.g. after iCloud's unsolicited FETCH FLAGS responses
during IDLE caused idle_check to raise). Mail that arrived during the
short disconnect window had a UID below the new baseline and was
silently dropped, so Poke never saw it.

Hoist last_seen_uid and last_uidvalidity to the outer reconnect loop
so the cursor survives transient disconnects. Reset only on first run
or when UIDVALIDITY changes (which means the server has reassigned
UIDs and the previous cursor is meaningless).

Apply the same fix to _poll_folder via a shared mutable cursor dict so
non-IDLE servers also retain their cursor across poll-error reconnects.

Fixes #5
2026-04-29 09:30:15 +02:00
Kacper Kwapisz 68c40055e7 Merge remote-tracking branch 'origin/main' into fix/issue-5-watcher-uid-tracking 2026-04-29 09:28:08 +02:00
Kacper Kwapisz 83a3c482ec Skip MCP_API_KEY generation in tunnel mode
start.sh previously generated and persisted an MCP_API_KEY to .env
unconditionally on first run. In tunnel mode (POKE_TUNNEL=1, the
default) the local server runs unauthenticated and the Poke tunnel
handles auth — generating a key there is at best useless and at worst
overwrites the user's pre-set key, causing 421 errors at the tunnel.

Move .env loading and POKE_TUNNEL resolution above the generation
block, and skip generation entirely when POKE_TUNNEL=1. Re-source .env
after a successful generation so the rest of the script sees the new
value.

Fixes #9
2026-04-29 09:27:59 +02:00
Kacper Kwapisz af796d8583 Send IMAP ID after login for providers that require it (netease)
Some providers (notably netease 163.com / 126.com / yeah.net) reject
clients that don't issue an RFC 2971 ID command after login. Send a
minimal client identification when the server advertises the ID
capability. Failures are logged at debug and never break login.

Fixes #10
2026-04-29 09:25:59 +02:00
0xK 3d4a1cb791 Fix UID-based tracking for already-Seen messages 2026-03-30 16:37:37 +02:00
7 changed files with 447 additions and 80 deletions
+48
View File
@@ -0,0 +1,48 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/ruff-action@v3
with:
args: check src/
- uses: astral-sh/ruff-action@v3
with:
args: format --check src/
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: pip install -r requirements.txt
- name: Verify imports
run: python -c "from src.server import mcp; print('OK:', mcp.name)"
docker:
runs-on: ubuntu-latest
needs: [lint, build]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: git.soconnor.dev
username: ${{ gitea.repository_owner }}
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: false
tags: poke-mail:test
cache-from: type=registry,ref=git.soconnor.dev/soconnor/poke-mail:buildcache
+44
View File
@@ -0,0 +1,44 @@
name: Build & Push Container Image
on:
push:
branches: [master]
tags: ["v*"]
env:
REGISTRY: git.soconnor.dev
IMAGE_NAME: soconnor/poke-mail
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.repository_owner }}
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max,image-manifest=true,oci-mediatypes=true
+5
View File
@@ -0,0 +1,5 @@
target-version = "py310"
[lint]
# Blind excepts and silent cleanup in mail parsing are intentional here.
ignore = ["BLE001", "S110"]
+21
View File
@@ -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.
+2 -2
View File
@@ -61,11 +61,11 @@ Set up poke-mail (https://github.com/kacperkwapisz/poke-mail) for me — clone t
cp config.example.yml config.yml
```
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
+306 -67
View File
@@ -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,19 +599,57 @@ async def watch_folder(
account["id"],
folder,
)
await _poll_folder(
client, account, folder, webhook_url, api_key, stop_event
)
# 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,
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)",
account["id"],
folder,
len(existing_unseen),
)
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,
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
while not stop_event.is_set():
@@ -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:
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,
)
continue
raw_messages = await asyncio.to_thread(
client.fetch, new_uids, ["RFC822"]
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,
)
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)
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(
+21 -11
View File
@@ -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