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.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField from backend.data.model import SchemaField
from backend.util.settings import Settings 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 ( from ._auth import (
GOOGLE_OAUTH_IS_CONFIGURED, GOOGLE_OAUTH_IS_CONFIGURED,
@@ -28,6 +30,7 @@ class Attachment(BaseModel):
class Email(BaseModel): class Email(BaseModel):
threadId: str
id: str id: str
subject: str subject: str
snippet: str snippet: str
@@ -82,6 +85,7 @@ class GmailReadBlock(Block):
( (
"email", "email",
{ {
"threadId": "t1",
"id": "1", "id": "1",
"subject": "Test Email", "subject": "Test Email",
"snippet": "This is a test email", "snippet": "This is a test email",
@@ -97,6 +101,7 @@ class GmailReadBlock(Block):
"emails", "emails",
[ [
{ {
"threadId": "t1",
"id": "1", "id": "1",
"subject": "Test Email", "subject": "Test Email",
"snippet": "This is a test email", "snippet": "This is a test email",
@@ -113,6 +118,7 @@ class GmailReadBlock(Block):
test_mock={ test_mock={
"_read_emails": lambda *args, **kwargs: [ "_read_emails": lambda *args, **kwargs: [
{ {
"threadId": "t1",
"id": "1", "id": "1",
"subject": "Test Email", "subject": "Test Email",
"snippet": "This is a test email", "snippet": "This is a test email",
@@ -185,6 +191,7 @@ class GmailReadBlock(Block):
attachments = self._get_attachments(service, msg) attachments = self._get_attachments(service, msg)
email = Email( email = Email(
threadId=msg["threadId"],
id=msg["id"], id=msg["id"],
subject=headers.get("subject", "No Subject"), subject=headers.get("subject", "No Subject"),
snippet=msg["snippet"], snippet=msg["snippet"],
@@ -528,3 +535,180 @@ class GmailRemoveLabelBlock(Block):
if label["name"] == label_name: if label["name"] == label_name:
return label["id"] return label["id"]
return None 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 | | Block Name | Description |
|------------|-------------| |------------|-------------|
| [Gmail Read](google/gmail.md#gmail-read) | Retrieves and reads emails from a Gmail account | | [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 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 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 | | [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 ### Outputs
| Output | Description | | 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 | | Emails | A list of email data for multiple emails |
| Error | An error message if something goes wrong during the process | | 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 | | Error | An error message if something goes wrong during the process |
### Possible use case ### 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.