Compare commits

...

2 Commits

Author SHA1 Message Date
Toran Bruce Richards
3f818ef157 Delete docs/content/platform/quickstarts/gmail-detect-reply.md 2025-05-17 20:31:01 +01:00
Toran Bruce Richards
636574a720 Add Gmail thread handling blocks 2025-05-17 13:42:07 +01:00
3 changed files with 248 additions and 2 deletions

View File

@@ -9,6 +9,8 @@ from pydantic import BaseModel
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField
from backend.util.settings import Settings
from backend.util.file import MediaFileType, store_media_file, get_exec_file_path
from pathlib import Path
from ._auth import (
GOOGLE_OAUTH_IS_CONFIGURED,
@@ -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"],
@@ -528,3 +535,180 @@ 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"]
)
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")
message: dict = SchemaField(description="Raw Gmail message object")
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)
yield "message", message
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.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email import encoders
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)
import base64
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()
)

View File

@@ -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 |

View File

@@ -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,64 @@ 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.
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 |
| Message | Full Gmail message object |
| 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.