From 64d795173c15e4a1283b109f5357904599136006 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Mon, 3 Aug 2026 01:56:58 -0400 Subject: [PATCH] Copy sent mail to Sent folder; add message field to Poke webhook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/server.py | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/server.py b/src/server.py index 2559ea3..a2cd73c 100644 --- a/src/server.py +++ b/src/server.py @@ -405,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"], @@ -1012,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)