Compare commits

...

2 Commits

Author SHA1 Message Date
Toran Bruce Richards
382d246d89 Delete docs/content/platform/quickstarts/gmail-detect-reply.md 2025-05-18 07:53:52 +01:00
Toran Bruce Richards
7e44b7cee7 fix reply block recipients 2025-05-18 07:51:14 +01:00
3 changed files with 266 additions and 4 deletions

View File

@@ -1,5 +1,6 @@
import base64 import base64
from email.utils import parseaddr from email.utils import parseaddr
from pathlib import Path
from typing import List from typing import List
from google.oauth2.credentials import Credentials 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.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField 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 backend.util.settings import Settings
from ._auth import ( from ._auth import (
@@ -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"],
@@ -243,7 +250,10 @@ class GmailReadBlock(Block):
class GmailSendBlock(Block): class GmailSendBlock(Block):
class Input(BlockSchema): class Input(BlockSchema):
credentials: GoogleCredentialsInput = GoogleCredentialsField( credentials: GoogleCredentialsInput = GoogleCredentialsField(
["https://www.googleapis.com/auth/gmail.send"] [
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/gmail.metadata",
]
) )
to: str = SchemaField( to: str = SchemaField(
description="Recipient email address", description="Recipient email address",
@@ -305,7 +315,6 @@ class GmailSendBlock(Block):
return {"id": sent_message["id"], "status": "sent"} return {"id": sent_message["id"], "status": "sent"}
def _create_message(self, to: str, subject: str, body: str) -> dict: def _create_message(self, to: str, subject: str, body: str) -> dict:
import base64
from email.mime.text import MIMEText from email.mime.text import MIMEText
message = MIMEText(body) message = MIMEText(body)
@@ -528,3 +537,195 @@ 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:
thread = (
service.users()
.threads()
.get(userId="me", id=thread_id, format="full")
.execute()
)
if not include_spam_trash:
thread["messages"] = [
m
for m in thread.get("messages", [])
if "SPAM" not in m.get("labelIds", [])
and "TRASH" not in m.get("labelIds", [])
]
return thread
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", "From"],
)
.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"])
parent_from = parseaddr(headers.get("from", ""))[1]
from email import encoders
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
msg = MIMEMultipart()
recipients = input_data.to or ([parent_from] if parent_from else [])
if not (recipients or input_data.cc or input_data.bcc):
raise ValueError("Recipient address required")
if recipients:
msg["To"] = ", ".join(recipients)
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()
)

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,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 | | 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 |
| 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.