diff --git a/autogpt_platform/backend/backend/blocks/google/gmail.py b/autogpt_platform/backend/backend/blocks/google/gmail.py index 780cc1b16f..61f6197693 100644 --- a/autogpt_platform/backend/backend/blocks/google/gmail.py +++ b/autogpt_platform/backend/backend/blocks/google/gmail.py @@ -1,5 +1,6 @@ import base64 from email.utils import parseaddr +from pathlib import Path from typing import List from google.oauth2.credentials import Credentials @@ -8,6 +9,7 @@ from pydantic import BaseModel from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema from backend.data.model import SchemaField +from backend.util.file import MediaFileType, get_exec_file_path, store_media_file from backend.util.settings import Settings from ._auth import ( @@ -28,6 +30,7 @@ class Attachment(BaseModel): class Email(BaseModel): + threadId: str id: str subject: str snippet: str @@ -82,6 +85,7 @@ class GmailReadBlock(Block): ( "email", { + "threadId": "t1", "id": "1", "subject": "Test Email", "snippet": "This is a test email", @@ -97,6 +101,7 @@ class GmailReadBlock(Block): "emails", [ { + "threadId": "t1", "id": "1", "subject": "Test Email", "snippet": "This is a test email", @@ -113,6 +118,7 @@ class GmailReadBlock(Block): test_mock={ "_read_emails": lambda *args, **kwargs: [ { + "threadId": "t1", "id": "1", "subject": "Test Email", "snippet": "This is a test email", @@ -185,6 +191,7 @@ class GmailReadBlock(Block): attachments = self._get_attachments(service, msg) email = Email( + threadId=msg["threadId"], id=msg["id"], subject=headers.get("subject", "No Subject"), snippet=msg["snippet"], @@ -305,7 +312,6 @@ class GmailSendBlock(Block): return {"id": sent_message["id"], "status": "sent"} def _create_message(self, to: str, subject: str, body: str) -> dict: - import base64 from email.mime.text import MIMEText message = MIMEText(body) @@ -528,3 +534,187 @@ class GmailRemoveLabelBlock(Block): if label["name"] == label_name: return label["id"] return None + + +class GmailGetThreadBlock(Block): + class Input(BlockSchema): + credentials: GoogleCredentialsInput = GoogleCredentialsField( + ["https://www.googleapis.com/auth/gmail.readonly"] + ) + threadId: str = SchemaField(description="Gmail thread ID") + includeSpamTrash: bool = SchemaField( + description="Include messages from Spam and Trash", default=False + ) + + class Output(BlockSchema): + thread: dict = SchemaField(description="Raw Gmail thread resource") + error: str = SchemaField(description="Error message if any") + + def __init__(self): + super().__init__( + id="21a79166-9df7-4b5f-9f36-96f639d86112", + description="Get a full Gmail thread by ID", + categories={BlockCategory.COMMUNICATION}, + input_schema=GmailGetThreadBlock.Input, + output_schema=GmailGetThreadBlock.Output, + disabled=not GOOGLE_OAUTH_IS_CONFIGURED, + test_input={"threadId": "t1", "credentials": TEST_CREDENTIALS_INPUT}, + test_credentials=TEST_CREDENTIALS, + test_output=[("thread", {"id": "t1"})], + test_mock={"_get_thread": lambda *args, **kwargs: {"id": "t1"}}, + ) + + def run( + self, input_data: Input, *, credentials: GoogleCredentials, **kwargs + ) -> BlockOutput: + service = GmailReadBlock._build_service(credentials, **kwargs) + thread = self._get_thread( + service, input_data.threadId, input_data.includeSpamTrash + ) + yield "thread", thread + + def _get_thread(self, service, thread_id: str, include_spam_trash: bool) -> dict: + return ( + service.users() + .threads() + .get( + userId="me", + id=thread_id, + format="full", + includeSpamTrash=include_spam_trash, + ) + .execute() + ) + + +class GmailReplyBlock(Block): + class Input(BlockSchema): + credentials: GoogleCredentialsInput = GoogleCredentialsField( + [ + "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/gmail.metadata", + ] + ) + threadId: str = SchemaField(description="Thread ID to reply in") + parentMessageId: str = SchemaField( + description="ID of the message being replied to" + ) + to: list[str] = SchemaField(description="To recipients", default_factory=list) + cc: list[str] = SchemaField(description="CC recipients", default_factory=list) + bcc: list[str] = SchemaField(description="BCC recipients", default_factory=list) + subject: str = SchemaField(description="Email subject", default="") + body: str = SchemaField(description="Email body") + attachments: list[MediaFileType] = SchemaField( + description="Files to attach", default_factory=list, advanced=True + ) + + class Output(BlockSchema): + messageId: str = SchemaField(description="Sent message ID") + threadId: str = SchemaField(description="Thread ID") + error: str = SchemaField(description="Error message if any") + + def __init__(self): + super().__init__( + id="12bf5a24-9b90-4f40-9090-4e86e6995e60", + description="Reply to a Gmail thread", + categories={BlockCategory.COMMUNICATION}, + input_schema=GmailReplyBlock.Input, + output_schema=GmailReplyBlock.Output, + disabled=not GOOGLE_OAUTH_IS_CONFIGURED, + test_input={ + "threadId": "t1", + "parentMessageId": "m1", + "body": "Thanks", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("messageId", "m2"), + ("threadId", "t1"), + ], + test_mock={ + "_reply": lambda *args, **kwargs: { + "id": "m2", + "threadId": "t1", + } + }, + ) + + def run( + self, + input_data: Input, + *, + credentials: GoogleCredentials, + graph_exec_id: str, + **kwargs, + ) -> BlockOutput: + service = GmailReadBlock._build_service(credentials, **kwargs) + message = self._reply( + service, + input_data, + graph_exec_id, + ) + yield "messageId", message["id"] + yield "threadId", message.get("threadId", input_data.threadId) + + def _reply(self, service, input_data: Input, graph_exec_id: str) -> dict: + parent = ( + service.users() + .messages() + .get( + userId="me", + id=input_data.parentMessageId, + format="metadata", + metadataHeaders=["Subject", "References", "Message-ID"], + ) + .execute() + ) + headers = { + h["name"].lower(): h["value"] + for h in parent.get("payload", {}).get("headers", []) + } + subject = input_data.subject or (f"Re: {headers.get('subject', '')}".strip()) + references = headers.get("references", "").split() + if headers.get("message-id"): + references.append(headers["message-id"]) + + from email import encoders + from email.mime.base import MIMEBase + from email.mime.multipart import MIMEMultipart + from email.mime.text import MIMEText + + msg = MIMEMultipart() + if input_data.to: + msg["To"] = ", ".join(input_data.to) + if input_data.cc: + msg["Cc"] = ", ".join(input_data.cc) + if input_data.bcc: + msg["Bcc"] = ", ".join(input_data.bcc) + msg["Subject"] = subject + if headers.get("message-id"): + msg["In-Reply-To"] = headers["message-id"] + if references: + msg["References"] = " ".join(references) + msg.attach( + MIMEText(input_data.body, "html" if "<" in input_data.body else "plain") + ) + + for attach in input_data.attachments: + local_path = store_media_file(graph_exec_id, attach, return_content=False) + abs_path = get_exec_file_path(graph_exec_id, local_path) + part = MIMEBase("application", "octet-stream") + with open(abs_path, "rb") as f: + part.set_payload(f.read()) + encoders.encode_base64(part) + part.add_header( + "Content-Disposition", f"attachment; filename={Path(abs_path).name}" + ) + msg.attach(part) + + raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8") + return ( + service.users() + .messages() + .send(userId="me", body={"threadId": input_data.threadId, "raw": raw}) + .execute() + ) diff --git a/docs/content/platform/blocks/blocks.md b/docs/content/platform/blocks/blocks.md index 3ca02d88d3..7fe5b8363c 100644 --- a/docs/content/platform/blocks/blocks.md +++ b/docs/content/platform/blocks/blocks.md @@ -99,6 +99,8 @@ Below is a comprehensive list of all available blocks, categorized by their prim | Block Name | Description | |------------|-------------| | [Gmail Read](google/gmail.md#gmail-read) | Retrieves and reads emails from a Gmail account | +| [Gmail Get Thread](google/gmail.md#gmail-get-thread) | Returns every message in a Gmail thread | +| [Gmail Reply](google/gmail.md#gmail-reply) | Sends a reply that stays in the same thread | | [Gmail Send](google/gmail.md#gmail-send) | Sends emails using a Gmail account | | [Gmail List Labels](google/gmail.md#gmail-list-labels) | Retrieves all labels from a Gmail account | | [Gmail Add Label](google/gmail.md#gmail-add-label) | Adds a label to a specific email in a Gmail account | diff --git a/docs/content/platform/blocks/google/gmail.md b/docs/content/platform/blocks/google/gmail.md index a23ffb874a..1607b475d1 100644 --- a/docs/content/platform/blocks/google/gmail.md +++ b/docs/content/platform/blocks/google/gmail.md @@ -21,7 +21,7 @@ The block connects to the user's Gmail account using their credentials, performs ### Outputs | Output | Description | |--------|-------------| -| Email | Detailed information about a single email | +| Email | Detailed information about a single email (now includes `threadId`) | | Emails | A list of email data for multiple emails | | Error | An error message if something goes wrong during the process | @@ -141,4 +141,63 @@ The block first finds the ID of the specified label in the user's Gmail account. | Error | An error message if something goes wrong during the process | ### Possible use case -Automatically removing the "Unread" label from emails after they have been processed by a customer service representative. \ No newline at end of file +Automatically removing the "Unread" label from emails after they have been processed by a customer service representative. + +--- + +## Gmail Get Thread + +### What it is +A block that retrieves an entire Gmail thread. + +### What it does +Given a `threadId`, this block fetches all messages in that thread. You can optionally include messages in Spam and Trash. + +### Inputs +| Input | Description | +|-------|-------------| +| Credentials | The user's Gmail account credentials for authentication | +| threadId | The ID of the thread to fetch | +| includeSpamTrash | Whether to include messages from Spam and Trash | + +### Outputs +| Output | Description | +|--------|-------------| +| Thread | The raw Gmail thread resource | +| Error | An error message if something goes wrong | + +### Possible use case +Checking if a recipient replied in an existing conversation. + +--- + +## Gmail Reply + +### What it is +A block that sends a reply within an existing Gmail thread. + +### What it does +This block builds a properly formatted reply email and sends it so Gmail keeps it in the same conversation. + +### Inputs +| Input | Description | +|-------|-------------| +| Credentials | The user's Gmail account credentials for authentication | +| threadId | The thread to reply in | +| parentMessageId | The ID of the message you are replying to | +| To | List of recipients | +| Cc | List of CC recipients | +| Bcc | List of BCC recipients | +| Subject | Optional subject (defaults to `Re:` prefix) | +| Body | The email body | +| Attachments | Optional files to include | + +### Outputs +| Output | Description | +|--------|-------------| +| MessageId | The ID of the sent message | +| ThreadId | The thread the reply belongs to | +| Error | Error message if something goes wrong | + +### Possible use case +Automatically respond "Thanks, see you then" to a scheduling email while keeping the conversation tidy. diff --git a/docs/content/platform/quickstarts/gmail-detect-reply.md b/docs/content/platform/quickstarts/gmail-detect-reply.md new file mode 100644 index 0000000000..59e05e92b9 --- /dev/null +++ b/docs/content/platform/quickstarts/gmail-detect-reply.md @@ -0,0 +1,11 @@ +# Detect replies to a scheduling email + +This quick-start flow demonstrates how to read an incoming message, capture its `threadId`, and then fetch the whole conversation to check for a reply. + +1. **Gmail Read** — search for your initial scheduling email and output its `threadId`. +2. **Gmail Get Thread** — pass the captured `threadId` to retrieve the conversation. +3. **Condition Block** — inspect the thread messages and branch if a reply is found. +4. **Gmail Reply** — send a follow-up in the same thread if needed. + +Use this pattern to keep email-based workflows organized without manual HTTP calls. +