From 2809c29bd7c7387714bb10721e6f262ed65d148b Mon Sep 17 00:00:00 2001 From: dimavedenyapin Date: Thu, 26 Mar 2026 23:12:55 +0700 Subject: [PATCH 01/10] feat(gmail): add track_delivery parameter to send_email tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When track_delivery=true, returns structured JSON with messageId, threadId, labelIds, and recipient info instead of plain text — enables delivery tracking in workflow-builder's McpToolNode without requiring a separate Gmail send node. Co-Authored-By: Claude Opus 4.6 --- src/servers/gmail/config.yaml | 2 +- src/servers/gmail/main.py | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/servers/gmail/config.yaml b/src/servers/gmail/config.yaml index 3d46b37..f46a9d2 100644 --- a/src/servers/gmail/config.yaml +++ b/src/servers/gmail/config.yaml @@ -6,7 +6,7 @@ tools: - name: "read_emails" description: "Search and read emails in Gmail with full text body and attachment information" - name: "send_email" - description: "Send an email through Gmail with optional attachments" + description: "Send an email through Gmail with optional attachments and delivery tracking" - name: "forward_email" description: "Forward an email to recipients, preserving original content and attachments" - name: "update_email" diff --git a/src/servers/gmail/main.py b/src/servers/gmail/main.py index adc72af..7d67c06 100644 --- a/src/servers/gmail/main.py +++ b/src/servers/gmail/main.py @@ -1,3 +1,4 @@ +import json import os import sys from typing import Optional, Iterable @@ -432,6 +433,10 @@ async def handle_list_tools() -> list[Tool]: "required": ["filename", "content", "mimeType"], }, }, + "track_delivery": { + "type": "boolean", + "description": "When true, returns structured JSON with messageId, threadId, and labelIds for delivery tracking instead of plain text", + }, }, "required": ["to", "subject", "body"], }, @@ -660,6 +665,27 @@ async def handle_call_tool( .execute() ) + track_delivery = arguments.get("track_delivery", False) + + if track_delivery: + # Return structured JSON for delivery tracking + tracking_data = { + "status": "sent", + "messageId": sent_message.get("id", ""), + "threadId": sent_message.get("threadId", ""), + "labelIds": sent_message.get("labelIds", []), + "to": arguments["to"], + "cc": arguments.get("cc", ""), + "bcc": arguments.get("bcc", ""), + "subject": arguments["subject"], + } + return [ + TextContent( + type="text", + text=json.dumps(tracking_data), + ) + ] + attachment_info = "" if arguments.get("attachments"): num_attachments = len(arguments["attachments"]) From cf1819b618654eedba8010cdbabe77d698bcec1d Mon Sep 17 00:00:00 2001 From: dimavedenyapin Date: Fri, 27 Mar 2026 08:54:28 +0700 Subject: [PATCH 02/10] refactor: use consistent tracking_message_id/tracking_thread_id across Gmail and Outlook Gmail: returns tracking_message_id (Gmail message id) and tracking_thread_id (threadId) Outlook: uses draft-then-send flow when track_delivery=true to capture message id and conversationId before sending, returns same tracking_message_id/tracking_thread_id shape Both providers return identical JSON shape: { "status": "sent", "tracking_message_id": "...", "tracking_thread_id": "..." } Co-Authored-By: Claude Opus 4.6 --- src/servers/gmail/main.py | 23 ++++--- src/servers/outlook/config.yaml | 2 +- src/servers/outlook/main.py | 102 +++++++++++++++++++++++++++----- 3 files changed, 99 insertions(+), 28 deletions(-) diff --git a/src/servers/gmail/main.py b/src/servers/gmail/main.py index 7d67c06..b93e652 100644 --- a/src/servers/gmail/main.py +++ b/src/servers/gmail/main.py @@ -667,17 +667,19 @@ async def handle_call_tool( track_delivery = arguments.get("track_delivery", False) + attachment_info = "" + if arguments.get("attachments"): + num_attachments = len(arguments["attachments"]) + attachment_info = f" with {num_attachments} attachment(s)" + if track_delivery: - # Return structured JSON for delivery tracking + # Return structured JSON with provider-agnostic tracking fields + # Consistent with Outlook MCP which returns the same shape + # (tracking_message_id = Graph message id, tracking_thread_id = conversationId) tracking_data = { "status": "sent", - "messageId": sent_message.get("id", ""), - "threadId": sent_message.get("threadId", ""), - "labelIds": sent_message.get("labelIds", []), - "to": arguments["to"], - "cc": arguments.get("cc", ""), - "bcc": arguments.get("bcc", ""), - "subject": arguments["subject"], + "tracking_message_id": sent_message.get("id", ""), + "tracking_thread_id": sent_message.get("threadId", ""), } return [ TextContent( @@ -686,11 +688,6 @@ async def handle_call_tool( ) ] - attachment_info = "" - if arguments.get("attachments"): - num_attachments = len(arguments["attachments"]) - attachment_info = f" with {num_attachments} attachment(s)" - return [ TextContent( type="text", diff --git a/src/servers/outlook/config.yaml b/src/servers/outlook/config.yaml index 4254565..856380f 100644 --- a/src/servers/outlook/config.yaml +++ b/src/servers/outlook/config.yaml @@ -6,7 +6,7 @@ tools: - name: "read_emails" description: "Read emails from Outlook. Fetches emails based on specified filters." - name: "send_email" - description: "Send an email using Outlook" + description: "Send an email using Outlook with optional delivery tracking" - name: "move_email" description: "Move an email to a different folder like inbox, sentitems, drafts using Outlook" - name: "forward_email" diff --git a/src/servers/outlook/main.py b/src/servers/outlook/main.py index 9c6a079..dc1c335 100644 --- a/src/servers/outlook/main.py +++ b/src/servers/outlook/main.py @@ -229,6 +229,10 @@ async def handle_list_tools() -> list[Tool]: "type": "string", "description": "BCC email addresses (comma-separated)", }, + "track_delivery": { + "type": "boolean", + "description": "When true, uses draft-then-send to return structured JSON with tracking_message_id and tracking_thread_id for delivery tracking", + }, }, "required": ["to", "subject", "body"], }, @@ -520,26 +524,96 @@ async def handle_call_tool( if email.strip() ] - # Prepare the email payload - email_payload = { - "message": { - "subject": subject, - "body": {"contentType": "Text", "content": body}, - "toRecipients": to_list, - "ccRecipients": cc_list, - "bccRecipients": bcc_list, - "internetMessageHeaders": [ - {"name": "X-Mailer", "value": "Microsoft Graph API"} - ], - }, - "saveToSentItems": "true", - } + track_delivery = arguments.get("track_delivery", False) headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", } + message_body = { + "subject": subject, + "body": {"contentType": "Text", "content": body}, + "toRecipients": to_list, + "ccRecipients": cc_list, + "bccRecipients": bcc_list, + "internetMessageHeaders": [ + {"name": "X-Mailer", "value": "Microsoft Graph API"} + ], + } + + if track_delivery: + # Draft-then-send: create draft first to get message ID, + # then send it. This lets us return tracking IDs. + logger.info("track_delivery=true, using draft-then-send flow") + + # Step 1: Create draft + draft_response = requests.post( + "https://graph.microsoft.com/v1.0/me/messages", + headers=headers, + data=json.dumps(message_body), + ) + + if draft_response.status_code != 201: + error_message = ( + draft_response.json() + .get("error", {}) + .get("message", "Unknown error") + ) + return [ + TextContent( + type="text", + text=f"Failed to create draft: {error_message}", + ) + ] + + draft = draft_response.json() + draft_id = draft.get("id", "") + conversation_id = draft.get("conversationId", "") + + # Step 2: Send the draft + send_response = requests.post( + f"https://graph.microsoft.com/v1.0/me/messages/{draft_id}/send", + headers=headers, + ) + + if send_response.status_code not in [200, 202]: + error_message = "Failed to send draft" + try: + error_message = ( + send_response.json() + .get("error", {}) + .get("message", error_message) + ) + except Exception: + pass + return [ + TextContent( + type="text", + text=f"Failed to send email: {error_message}", + ) + ] + + # Return structured JSON with provider-agnostic tracking fields + # Consistent with Gmail MCP which returns the same shape + tracking_data = { + "status": "sent", + "tracking_message_id": draft_id, + "tracking_thread_id": conversation_id, + } + return [ + TextContent( + type="text", + text=json.dumps(tracking_data), + ) + ] + + # Standard send (no tracking) — uses sendMail endpoint directly + email_payload = { + "message": message_body, + "saveToSentItems": "true", + } + # Log the request details logger.info(f"Sending email with payload: {email_payload}") From 44ba7ad23e60ec1908769826c8b96cb33b82e22d Mon Sep 17 00:00:00 2001 From: dimavedenyapin Date: Fri, 27 Mar 2026 09:14:26 +0700 Subject: [PATCH 03/10] refactor: use channelMessageId to match workflo's messages schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both Gmail and Outlook send_email now return: { "status": "sent", "channelMessageId": "..." } This maps directly to workflo's messages.channel_message_id column, which the AddConversationMessageNode uses to store the external message ID. Dropped tracking_thread_id — workflo uses sessionId (managed by the workflow), not provider-side thread/conversation IDs. Co-Authored-By: Claude Opus 4.6 --- src/servers/gmail/main.py | 11 +++++------ src/servers/outlook/main.py | 11 +++++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/servers/gmail/main.py b/src/servers/gmail/main.py index b93e652..882f415 100644 --- a/src/servers/gmail/main.py +++ b/src/servers/gmail/main.py @@ -435,7 +435,7 @@ async def handle_list_tools() -> list[Tool]: }, "track_delivery": { "type": "boolean", - "description": "When true, returns structured JSON with messageId, threadId, and labelIds for delivery tracking instead of plain text", + "description": "When true, returns structured JSON with channelMessageId for delivery tracking", }, }, "required": ["to", "subject", "body"], @@ -673,13 +673,12 @@ async def handle_call_tool( attachment_info = f" with {num_attachments} attachment(s)" if track_delivery: - # Return structured JSON with provider-agnostic tracking fields - # Consistent with Outlook MCP which returns the same shape - # (tracking_message_id = Graph message id, tracking_thread_id = conversationId) + # Return structured JSON with tracking fields. + # channelMessageId maps to workflo's messages.channel_message_id column + # for matching delivery events back to the sent message. tracking_data = { "status": "sent", - "tracking_message_id": sent_message.get("id", ""), - "tracking_thread_id": sent_message.get("threadId", ""), + "channelMessageId": sent_message.get("id", ""), } return [ TextContent( diff --git a/src/servers/outlook/main.py b/src/servers/outlook/main.py index dc1c335..b698f2e 100644 --- a/src/servers/outlook/main.py +++ b/src/servers/outlook/main.py @@ -231,7 +231,7 @@ async def handle_list_tools() -> list[Tool]: }, "track_delivery": { "type": "boolean", - "description": "When true, uses draft-then-send to return structured JSON with tracking_message_id and tracking_thread_id for delivery tracking", + "description": "When true, uses draft-then-send to return structured JSON with channelMessageId for delivery tracking", }, }, "required": ["to", "subject", "body"], @@ -569,7 +569,6 @@ async def handle_call_tool( draft = draft_response.json() draft_id = draft.get("id", "") - conversation_id = draft.get("conversationId", "") # Step 2: Send the draft send_response = requests.post( @@ -594,12 +593,12 @@ async def handle_call_tool( ) ] - # Return structured JSON with provider-agnostic tracking fields - # Consistent with Gmail MCP which returns the same shape + # Return structured JSON with tracking fields. + # channelMessageId maps to workflo's messages.channel_message_id column + # for matching delivery events back to the sent message. tracking_data = { "status": "sent", - "tracking_message_id": draft_id, - "tracking_thread_id": conversation_id, + "channelMessageId": draft_id, } return [ TextContent( From 5cc414fdd14b4260cdb44c15576eb8e202122076 Mon Sep 17 00:00:00 2001 From: dimavedenyapin Date: Fri, 27 Mar 2026 09:23:27 +0700 Subject: [PATCH 04/10] Add conversationId to track_delivery response for Gmail and Outlook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both send_email tools now return conversationId alongside channelMessageId when track_delivery is true. Gmail maps threadId → conversationId, Outlook returns its native conversationId from the draft. Co-Authored-By: Claude Opus 4.6 --- src/servers/gmail/main.py | 5 ++++- src/servers/outlook/main.py | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/servers/gmail/main.py b/src/servers/gmail/main.py index 882f415..0229b52 100644 --- a/src/servers/gmail/main.py +++ b/src/servers/gmail/main.py @@ -435,7 +435,7 @@ async def handle_list_tools() -> list[Tool]: }, "track_delivery": { "type": "boolean", - "description": "When true, returns structured JSON with channelMessageId for delivery tracking", + "description": "When true, returns structured JSON with channelMessageId and conversationId for delivery tracking", }, }, "required": ["to", "subject", "body"], @@ -676,9 +676,12 @@ async def handle_call_tool( # Return structured JSON with tracking fields. # channelMessageId maps to workflo's messages.channel_message_id column # for matching delivery events back to the sent message. + # conversationId maps to Gmail's threadId — used to group messages + # in the same email thread. tracking_data = { "status": "sent", "channelMessageId": sent_message.get("id", ""), + "conversationId": sent_message.get("threadId", ""), } return [ TextContent( diff --git a/src/servers/outlook/main.py b/src/servers/outlook/main.py index b698f2e..90df2fc 100644 --- a/src/servers/outlook/main.py +++ b/src/servers/outlook/main.py @@ -231,7 +231,7 @@ async def handle_list_tools() -> list[Tool]: }, "track_delivery": { "type": "boolean", - "description": "When true, uses draft-then-send to return structured JSON with channelMessageId for delivery tracking", + "description": "When true, uses draft-then-send to return structured JSON with channelMessageId and conversationId for delivery tracking", }, }, "required": ["to", "subject", "body"], @@ -596,9 +596,13 @@ async def handle_call_tool( # Return structured JSON with tracking fields. # channelMessageId maps to workflo's messages.channel_message_id column # for matching delivery events back to the sent message. + # conversationId maps to Outlook's conversationId — used to group + # messages in the same email thread. + conversation_id = draft.get("conversationId", "") tracking_data = { "status": "sent", "channelMessageId": draft_id, + "conversationId": conversation_id, } return [ TextContent( From 550bf6c4389de27eda2bbc65a4893abafa05c808 Mon Sep 17 00:00:00 2001 From: dimavedenyapin Date: Fri, 27 Mar 2026 09:43:53 +0700 Subject: [PATCH 05/10] Set up Gmail Watch and Outlook webhook when track_delivery is true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gmail: calls users().watch() with GMAIL_PUBSUB_TOPIC env var on each tracked send (idempotent, safe to repeat). Watches SENT + INBOX labels. Outlook: checks for existing Graph subscription before creating one. Uses OUTLOOK_WEBHOOK_URL env var. Subscription covers me/messages with created+updated change types, max 4230-minute expiry. Both are non-blocking — failures are logged but don't prevent the email send or tracking response. Co-Authored-By: Claude Opus 4.6 --- src/servers/gmail/main.py | 27 ++++++++++++++ src/servers/outlook/main.py | 70 +++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/src/servers/gmail/main.py b/src/servers/gmail/main.py index 0229b52..49e6865 100644 --- a/src/servers/gmail/main.py +++ b/src/servers/gmail/main.py @@ -673,6 +673,33 @@ async def handle_call_tool( attachment_info = f" with {num_attachments} attachment(s)" if track_delivery: + # Ensure Gmail push notifications are active so delivery + # events (bounces, reads, etc.) are forwarded via Pub/Sub. + # users().watch() is idempotent — safe to call on every send. + pubsub_topic = os.environ.get("GMAIL_PUBSUB_TOPIC") + if pubsub_topic: + try: + watch_response = ( + gmail_service.users() + .watch( + userId="me", + body={ + "topicName": pubsub_topic, + "labelIds": ["SENT", "INBOX"], + "labelFilterBehavior": "INCLUDE", + }, + ) + .execute() + ) + logger.info( + f"Gmail watch active — historyId={watch_response.get('historyId')}, " + f"expiration={watch_response.get('expiration')}" + ) + except Exception as watch_err: + logger.warning(f"Failed to set Gmail watch (non-blocking): {watch_err}") + else: + logger.debug("GMAIL_PUBSUB_TOPIC not set, skipping watch setup") + # Return structured JSON with tracking fields. # channelMessageId maps to workflo's messages.channel_message_id column # for matching delivery events back to the sent message. diff --git a/src/servers/outlook/main.py b/src/servers/outlook/main.py index 90df2fc..b72648c 100644 --- a/src/servers/outlook/main.py +++ b/src/servers/outlook/main.py @@ -12,6 +12,7 @@ import json import logging +from datetime import datetime, timedelta, timezone from html import unescape from pathlib import Path @@ -62,6 +63,64 @@ async def create_outlook_client(user_id, api_key=None): raise +def _ensure_outlook_subscription(headers: dict, webhook_url: str) -> None: + """Ensure an active Microsoft Graph subscription exists for mail messages. + + Outlook subscriptions are NOT idempotent — we check for an existing one + before creating. The subscription covers created/updated messages so we + can track delivery events (bounces, read receipts, etc.). + + Max subscription lifetime for mail resources is 4230 minutes (~2.9 days). + """ + MAIL_RESOURCE = "me/messages" + MAX_EXPIRY_MINUTES = 4230 # Graph API maximum for mail resources + + # Check for existing subscriptions on the mail resource + list_resp = requests.get( + "https://graph.microsoft.com/v1.0/subscriptions", + headers=headers, + ) + + if list_resp.status_code == 200: + for sub in list_resp.json().get("value", []): + if sub.get("resource") == MAIL_RESOURCE and sub.get("notificationUrl") == webhook_url: + # Already have an active subscription for this resource + webhook + logger.info( + f"Outlook subscription already active — id={sub.get('id')}, " + f"expires={sub.get('expirationDateTime')}" + ) + return + + # No matching subscription found — create one + expiration = ( + datetime.now(timezone.utc) + timedelta(minutes=MAX_EXPIRY_MINUTES) + ).strftime("%Y-%m-%dT%H:%M:%S.0000000Z") + + subscription_body = { + "changeType": "created,updated", + "notificationUrl": webhook_url, + "resource": MAIL_RESOURCE, + "expirationDateTime": expiration, + "clientState": "delivery-tracking", + } + + create_resp = requests.post( + "https://graph.microsoft.com/v1.0/subscriptions", + headers=headers, + data=json.dumps(subscription_body), + ) + + if create_resp.status_code == 201: + sub_data = create_resp.json() + logger.info( + f"Outlook subscription created — id={sub_data.get('id')}, " + f"expires={sub_data.get('expirationDateTime')}" + ) + else: + error_msg = create_resp.json().get("error", {}).get("message", create_resp.text) + logger.warning(f"Failed to create Outlook subscription: {error_msg}") + + def create_server(user_id, api_key=None): """Create a new server instance with optional user context""" server = Server("outlook-server") @@ -593,6 +652,17 @@ async def handle_call_tool( ) ] + # Ensure Outlook change-notification subscription is active + # so delivery events are forwarded to our webhook endpoint. + webhook_url = os.environ.get("OUTLOOK_WEBHOOK_URL") + if webhook_url: + try: + _ensure_outlook_subscription(headers, webhook_url) + except Exception as sub_err: + logger.warning(f"Failed to set Outlook subscription (non-blocking): {sub_err}") + else: + logger.debug("OUTLOOK_WEBHOOK_URL not set, skipping subscription setup") + # Return structured JSON with tracking fields. # channelMessageId maps to workflo's messages.channel_message_id column # for matching delivery events back to the sent message. From 9f4eb462ef90d497a01462ba77d992f9df56d4c0 Mon Sep 17 00:00:00 2001 From: dimavedenyapin Date: Fri, 27 Mar 2026 09:52:01 +0700 Subject: [PATCH 06/10] Add GMAIL_PUBSUB_TOPIC and OUTLOOK_WEBHOOK_URL to CI/CD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire both env vars into stage and production deploy workflows via GitHub vars (INT_/PROD_ prefixed). Also add to .env.example for local development reference. These are optional — watch/webhook setup is skipped when unset. Co-Authored-By: Claude Opus 4.6 --- .env.example | 6 +++++- .github/workflows/production.yml | 2 ++ .github/workflows/stage.yml | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 9154354..c15c182 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,8 @@ NANGO_HOST=https://api.nango.dev STORAGE_PROVIDER=local # "local" for development, "gcs" for production LOCAL_STORAGE_DIR=/tmp/pfmcp-attachments # local provider only -GCS_BUCKET_NAME=your-bucket-name # gcs provider only \ No newline at end of file +GCS_BUCKET_NAME=your-bucket-name # gcs provider only + +# Delivery tracking (optional — watch/webhook only set up when defined) +GMAIL_PUBSUB_TOPIC=projects/your-project/topics/gmail-notifications +OUTLOOK_WEBHOOK_URL=https://your-domain.com/api/outlook/webhook \ No newline at end of file diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 42c866d..1640e28 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -30,6 +30,8 @@ jobs: echo "NANGO_SECRET_KEY: ${{ secrets.PROD_NANGO_SECRET_KEY }}" >> .envprod.yaml echo 'PEAKFLO_API_BASE_URL: "https://api.peakflo.co/v1"' >> .envprod.yaml echo "GCS_BUCKET_NAME: ${{ vars.PROD_GCS_BUCKET_NAME }}" >> .envprod.yaml + echo "GMAIL_PUBSUB_TOPIC: ${{ vars.PROD_GMAIL_PUBSUB_TOPIC }}" >> .envprod.yaml + echo "OUTLOOK_WEBHOOK_URL: ${{ vars.PROD_OUTLOOK_WEBHOOK_URL }}" >> .envprod.yaml - name: GCP Auth uses: google-github-actions/auth@v2.1.2 diff --git a/.github/workflows/stage.yml b/.github/workflows/stage.yml index f47a86e..82b829c 100644 --- a/.github/workflows/stage.yml +++ b/.github/workflows/stage.yml @@ -30,6 +30,8 @@ jobs: echo "NANGO_SECRET_KEY: ${{ secrets.INT_NANGO_SECRET_KEY }}" >> .envstage.yaml echo 'PEAKFLO_API_BASE_URL: "https://stage-api.peakflo.co/v1"' >> .envstage.yaml echo "GCS_BUCKET_NAME: ${{ vars.INT_GCS_BUCKET_NAME }}" >> .envstage.yaml + echo "GMAIL_PUBSUB_TOPIC: ${{ vars.INT_GMAIL_PUBSUB_TOPIC }}" >> .envstage.yaml + echo "OUTLOOK_WEBHOOK_URL: ${{ vars.INT_OUTLOOK_WEBHOOK_URL }}" >> .envstage.yaml - name: GCP Auth uses: google-github-actions/auth@v2.1.2 From e5b095dda75ff1d63c73d5fefe97045d12d55b24 Mon Sep 17 00:00:00 2001 From: dimavedenyapin Date: Fri, 27 Mar 2026 10:38:52 +0700 Subject: [PATCH 07/10] Fix black formatting in gmail and outlook server files Apply black formatter to src/servers/gmail/main.py and src/servers/outlook/main.py to pass the format-check CI job. Co-Authored-By: Claude Opus 4.6 --- src/servers/gmail/main.py | 4 +++- src/servers/outlook/main.py | 13 ++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/servers/gmail/main.py b/src/servers/gmail/main.py index 49e6865..03d05b9 100644 --- a/src/servers/gmail/main.py +++ b/src/servers/gmail/main.py @@ -696,7 +696,9 @@ async def handle_call_tool( f"expiration={watch_response.get('expiration')}" ) except Exception as watch_err: - logger.warning(f"Failed to set Gmail watch (non-blocking): {watch_err}") + logger.warning( + f"Failed to set Gmail watch (non-blocking): {watch_err}" + ) else: logger.debug("GMAIL_PUBSUB_TOPIC not set, skipping watch setup") diff --git a/src/servers/outlook/main.py b/src/servers/outlook/main.py index b72648c..ca8e133 100644 --- a/src/servers/outlook/main.py +++ b/src/servers/outlook/main.py @@ -83,7 +83,10 @@ def _ensure_outlook_subscription(headers: dict, webhook_url: str) -> None: if list_resp.status_code == 200: for sub in list_resp.json().get("value", []): - if sub.get("resource") == MAIL_RESOURCE and sub.get("notificationUrl") == webhook_url: + if ( + sub.get("resource") == MAIL_RESOURCE + and sub.get("notificationUrl") == webhook_url + ): # Already have an active subscription for this resource + webhook logger.info( f"Outlook subscription already active — id={sub.get('id')}, " @@ -659,9 +662,13 @@ async def handle_call_tool( try: _ensure_outlook_subscription(headers, webhook_url) except Exception as sub_err: - logger.warning(f"Failed to set Outlook subscription (non-blocking): {sub_err}") + logger.warning( + f"Failed to set Outlook subscription (non-blocking): {sub_err}" + ) else: - logger.debug("OUTLOOK_WEBHOOK_URL not set, skipping subscription setup") + logger.debug( + "OUTLOOK_WEBHOOK_URL not set, skipping subscription setup" + ) # Return structured JSON with tracking fields. # channelMessageId maps to workflo's messages.channel_message_id column From 698b3e830489149155e1204efc6e4c21f590a128 Mon Sep 17 00:00:00 2001 From: dimavedenyapin Date: Sat, 28 Mar 2026 14:25:10 +0700 Subject: [PATCH 08/10] Restore .dockerignore and remote.py accidentally modified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These files were unintentionally changed — .dockerignore was deleted and remote.py had debug=True hardcoded + server names exposed in health checks. Restoring both to their state on main. Co-Authored-By: Claude Opus 4.6 --- .dockerignore | 31 +++++++++++++++++++++++++++++++ src/servers/remote.py | 16 ++++++++++------ 2 files changed, 41 insertions(+), 6 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8d60f1a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# Git +.git/ +.github/ + +# Environment and secrets +.env +.env.* +.envprod.yaml +.envstage.yaml + +# Local credentials +local_auth/ +credentials.json +oauth.keys.json + +# IDE +.vscode/ +.cursor/ +.idea/ + +# Development +scratch/ +tests/ +*.log +__pycache__/ +*.py[cod] +.pytest_cache/ + +# Documentation +*.md +!README.md diff --git a/src/servers/remote.py b/src/servers/remote.py index 0baa4bf..9b3b103 100644 --- a/src/servers/remote.py +++ b/src/servers/remote.py @@ -1,4 +1,5 @@ import logging +import os import uvicorn import argparse import importlib.util @@ -14,6 +15,9 @@ from starlette.types import Receive, Scope, Send from prometheus_client import Counter, Gauge, generate_latest, CONTENT_TYPE_LATEST +# Production mode: set DEBUG=true in environment to enable debug mode +DEBUG_MODE = os.environ.get("DEBUG", "false").lower() == "true" + from mcp.server.lowlevel import Server from mcp.server import streamable_http_manager @@ -111,7 +115,7 @@ async def metrics_endpoint(request): routes = [Route("/metrics", endpoint=metrics_endpoint)] app = Starlette( - debug=True, + debug=DEBUG_MODE, routes=routes, ) @@ -214,15 +218,15 @@ async def handle_server_request( logger.info(f"Added stateless routes for server: {server_name}") - # Health checks + # Health checks — do not expose server list in unauthenticated endpoints async def root_handler(request): """Root endpoint that returns a simple 200 OK response""" return JSONResponse( { "status": "ok", - "message": "guMCP stateless server running", - "servers": list(servers.keys()), + "message": "pfMCP server running", "mode": "stateless", + "server_count": len(servers), } ) @@ -231,7 +235,7 @@ async def root_handler(request): async def health_check(request): """Health check endpoint""" return JSONResponse( - {"status": "ok", "servers": list(servers.keys()), "mode": "stateless"} + {"status": "ok", "mode": "stateless", "server_count": len(servers)} ) routes.append(Route("/health_check", endpoint=health_check)) @@ -246,7 +250,7 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]: logger.info("Application shutting down...") app = Starlette( - debug=True, + debug=DEBUG_MODE, routes=routes, lifespan=lifespan, ) From 13095d30e0b9be6e3f158a1179ec158c8e54dce3 Mon Sep 17 00:00:00 2001 From: dimavedenyapin Date: Sat, 28 Mar 2026 14:52:06 +0700 Subject: [PATCH 09/10] Use internetMessageId instead of draft ID for Outlook channelMessageId The Graph object ID changes when a message moves from Drafts to Sent Items after send. internetMessageId (RFC 2822 Message-ID header) is stable across this transition, so webhook notifications can be matched back to the sent message in our DB. Co-Authored-By: Claude Opus 4.6 --- src/servers/outlook/main.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/servers/outlook/main.py b/src/servers/outlook/main.py index ca8e133..a712cf0 100644 --- a/src/servers/outlook/main.py +++ b/src/servers/outlook/main.py @@ -631,6 +631,13 @@ async def handle_call_tool( draft = draft_response.json() draft_id = draft.get("id", "") + # internetMessageId is the RFC 2822 Message-ID header — it is + # stable across draft→send (unlike the Graph object ID which + # changes when the message moves from Drafts to Sent Items). + # This is the value we store as channelMessageId so that + # webhook notifications can be matched back to this message. + internet_message_id = draft.get("internetMessageId", "") + conversation_id = draft.get("conversationId", "") # Step 2: Send the draft send_response = requests.post( @@ -671,14 +678,14 @@ async def handle_call_tool( ) # Return structured JSON with tracking fields. - # channelMessageId maps to workflo's messages.channel_message_id column - # for matching delivery events back to the sent message. - # conversationId maps to Outlook's conversationId — used to group - # messages in the same email thread. - conversation_id = draft.get("conversationId", "") + # channelMessageId uses internetMessageId (RFC 2822 Message-ID) + # which is stable across draft→send, unlike the Graph object ID + # that changes when the message moves to Sent Items. + # This maps to workflo's messages.channel_message_id column + # for matching webhook delivery events back to the sent message. tracking_data = { "status": "sent", - "channelMessageId": draft_id, + "channelMessageId": internet_message_id, "conversationId": conversation_id, } return [ From c815b2b4abb83da6b60b446e8c3a833a276a7e43 Mon Sep 17 00:00:00 2001 From: dimavedenyapin Date: Sun, 29 Mar 2026 09:02:38 +0700 Subject: [PATCH 10/10] Always return channelMessageId and conversationId regardless of track_delivery Gmail: structured JSON with channelMessageId/conversationId is now always returned. track_delivery only controls whether Gmail Watch (Pub/Sub) is set up. Outlook: always uses draft-then-send flow (since /me/sendMail returns 202 with empty body). track_delivery only controls webhook subscription setup. Co-Authored-By: Claude Opus 4.6 --- src/servers/gmail/main.py | 29 +++--- src/servers/outlook/main.py | 172 +++++++++++++++--------------------- 2 files changed, 80 insertions(+), 121 deletions(-) diff --git a/src/servers/gmail/main.py b/src/servers/gmail/main.py index 03d05b9..db1b663 100644 --- a/src/servers/gmail/main.py +++ b/src/servers/gmail/main.py @@ -702,27 +702,20 @@ async def handle_call_tool( else: logger.debug("GMAIL_PUBSUB_TOPIC not set, skipping watch setup") - # Return structured JSON with tracking fields. - # channelMessageId maps to workflo's messages.channel_message_id column - # for matching delivery events back to the sent message. - # conversationId maps to Gmail's threadId — used to group messages - # in the same email thread. - tracking_data = { - "status": "sent", - "channelMessageId": sent_message.get("id", ""), - "conversationId": sent_message.get("threadId", ""), - } - return [ - TextContent( - type="text", - text=json.dumps(tracking_data), - ) - ] - + # Always return structured JSON with tracking fields. + # channelMessageId maps to workflo's messages.channel_message_id column + # for matching delivery events back to the sent message. + # conversationId maps to Gmail's threadId — used to group messages + # in the same email thread. + result_data = { + "status": "sent", + "channelMessageId": sent_message.get("id", ""), + "conversationId": sent_message.get("threadId", ""), + } return [ TextContent( type="text", - text=f"Email sent successfully to {arguments['to']}{attachment_info}. Message ID: {sent_message['id']}", + text=json.dumps(result_data), ) ] except Exception as e: diff --git a/src/servers/outlook/main.py b/src/servers/outlook/main.py index a712cf0..907c25a 100644 --- a/src/servers/outlook/main.py +++ b/src/servers/outlook/main.py @@ -604,64 +604,66 @@ async def handle_call_tool( ], } - if track_delivery: - # Draft-then-send: create draft first to get message ID, - # then send it. This lets us return tracking IDs. - logger.info("track_delivery=true, using draft-then-send flow") - - # Step 1: Create draft - draft_response = requests.post( - "https://graph.microsoft.com/v1.0/me/messages", - headers=headers, - data=json.dumps(message_body), + # Always use draft-then-send flow to capture message IDs. + # /me/sendMail returns 202 with empty body (no IDs available), + # so draft-then-send is required to get channelMessageId and + # conversationId regardless of whether tracking is enabled. + logger.info("Using draft-then-send flow to capture message IDs") + + # Step 1: Create draft + draft_response = requests.post( + "https://graph.microsoft.com/v1.0/me/messages", + headers=headers, + data=json.dumps(message_body), + ) + + if draft_response.status_code != 201: + error_message = ( + draft_response.json() + .get("error", {}) + .get("message", "Unknown error") ) + return [ + TextContent( + type="text", + text=f"Failed to create draft: {error_message}", + ) + ] - if draft_response.status_code != 201: + draft = draft_response.json() + draft_id = draft.get("id", "") + # internetMessageId is the RFC 2822 Message-ID header — it is + # stable across draft→send (unlike the Graph object ID which + # changes when the message moves from Drafts to Sent Items). + # This is the value we store as channelMessageId so that + # webhook notifications can be matched back to this message. + internet_message_id = draft.get("internetMessageId", "") + conversation_id = draft.get("conversationId", "") + + # Step 2: Send the draft + send_response = requests.post( + f"https://graph.microsoft.com/v1.0/me/messages/{draft_id}/send", + headers=headers, + ) + + if send_response.status_code not in [200, 202]: + error_message = "Failed to send draft" + try: error_message = ( - draft_response.json() + send_response.json() .get("error", {}) - .get("message", "Unknown error") + .get("message", error_message) ) - return [ - TextContent( - type="text", - text=f"Failed to create draft: {error_message}", - ) - ] - - draft = draft_response.json() - draft_id = draft.get("id", "") - # internetMessageId is the RFC 2822 Message-ID header — it is - # stable across draft→send (unlike the Graph object ID which - # changes when the message moves from Drafts to Sent Items). - # This is the value we store as channelMessageId so that - # webhook notifications can be matched back to this message. - internet_message_id = draft.get("internetMessageId", "") - conversation_id = draft.get("conversationId", "") - - # Step 2: Send the draft - send_response = requests.post( - f"https://graph.microsoft.com/v1.0/me/messages/{draft_id}/send", - headers=headers, - ) - - if send_response.status_code not in [200, 202]: - error_message = "Failed to send draft" - try: - error_message = ( - send_response.json() - .get("error", {}) - .get("message", error_message) - ) - except Exception: - pass - return [ - TextContent( - type="text", - text=f"Failed to send email: {error_message}", - ) - ] + except Exception: + pass + return [ + TextContent( + type="text", + text=f"Failed to send email: {error_message}", + ) + ] + if track_delivery: # Ensure Outlook change-notification subscription is active # so delivery events are forwarded to our webhook endpoint. webhook_url = os.environ.get("OUTLOOK_WEBHOOK_URL") @@ -677,59 +679,23 @@ async def handle_call_tool( "OUTLOOK_WEBHOOK_URL not set, skipping subscription setup" ) - # Return structured JSON with tracking fields. - # channelMessageId uses internetMessageId (RFC 2822 Message-ID) - # which is stable across draft→send, unlike the Graph object ID - # that changes when the message moves to Sent Items. - # This maps to workflo's messages.channel_message_id column - # for matching webhook delivery events back to the sent message. - tracking_data = { - "status": "sent", - "channelMessageId": internet_message_id, - "conversationId": conversation_id, - } - return [ - TextContent( - type="text", - text=json.dumps(tracking_data), - ) - ] - - # Standard send (no tracking) — uses sendMail endpoint directly - email_payload = { - "message": message_body, - "saveToSentItems": "true", + # Always return structured JSON with tracking fields. + # channelMessageId uses internetMessageId (RFC 2822 Message-ID) + # which is stable across draft→send, unlike the Graph object ID + # that changes when the message moves to Sent Items. + # This maps to workflo's messages.channel_message_id column + # for matching webhook delivery events back to the sent message. + result_data = { + "status": "sent", + "channelMessageId": internet_message_id, + "conversationId": conversation_id, } - - # Log the request details - logger.info(f"Sending email with payload: {email_payload}") - - response = requests.post( - "https://graph.microsoft.com/v1.0/me/sendMail", - headers=headers, - data=json.dumps(email_payload), - ) - - # Log the response - logger.info(f"Response status code: {response.status_code}") - logger.info(f"Response content: {response.content}") - - if response.status_code in [200, 202]: - return [ - TextContent( - type="text", - text=f"Email sent successfully to {', '.join(to_recipients)}", - ) - ] - else: - error_message = ( - response.json().get("error", {}).get("message", "Unknown error") + return [ + TextContent( + type="text", + text=json.dumps(result_data), ) - return [ - TextContent( - type="text", text=f"Failed to send email: {error_message}" - ) - ] + ] except Exception as e: logger.error(f"Error in send_email: {str(e)}")