Add In-Reply-To to Email Workflow Node (#18641)
Co-authored-by: github-actions <github-actions@twenty.com> Co-authored-by: Charles Bochet <charles@twenty.com>
@@ -175,7 +175,8 @@
|
||||
"user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -621,7 +622,8 @@
|
||||
"l/fr/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/fr/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/fr/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/fr/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/fr/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/fr/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1067,7 +1069,8 @@
|
||||
"l/ar/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/ar/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/ar/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/ar/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/ar/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/ar/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1513,7 +1516,8 @@
|
||||
"l/cs/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/cs/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/cs/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/cs/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/cs/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/cs/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1959,7 +1963,8 @@
|
||||
"l/de/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/de/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/de/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/de/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/de/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/de/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -2405,7 +2410,8 @@
|
||||
"l/es/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/es/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/es/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/es/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/es/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/es/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -2851,7 +2857,8 @@
|
||||
"l/it/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/it/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/it/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/it/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/it/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/it/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -3297,7 +3304,8 @@
|
||||
"l/ja/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/ja/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/ja/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/ja/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/ja/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/ja/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -3743,7 +3751,8 @@
|
||||
"l/ko/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/ko/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/ko/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/ko/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/ko/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/ko/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -4189,7 +4198,8 @@
|
||||
"l/pt/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/pt/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/pt/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/pt/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/pt/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/pt/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -4635,7 +4645,8 @@
|
||||
"l/ro/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/ro/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/ro/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/ro/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/ro/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/ro/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -5081,7 +5092,8 @@
|
||||
"l/ru/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/ru/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/ru/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/ru/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/ru/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/ru/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -5527,7 +5539,8 @@
|
||||
"l/tr/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/tr/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/tr/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/tr/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/tr/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/tr/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -5973,7 +5986,8 @@
|
||||
"l/zh/user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"l/zh/user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"l/zh/user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"l/zh/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"l/zh/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"l/zh/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 931 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 224 KiB |
@@ -157,7 +157,8 @@
|
||||
"user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -156,7 +156,8 @@
|
||||
"user-guide/workflows/how-tos/crm-automations/formula-fields",
|
||||
"user-guide/workflows/how-tos/crm-automations/display-related-record-data",
|
||||
"user-guide/workflows/how-tos/crm-automations/closed-won-automations",
|
||||
"user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities"
|
||||
"user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities",
|
||||
"user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: Auto-Reply to Inbound Emails
|
||||
description: Build a workflow that uses AI to triage incoming emails and send threaded replies automatically.
|
||||
---
|
||||
|
||||
Respond to inbound emails in seconds — not hours. This workflow uses an AI Agent to filter out noise (newsletters, spam, auto-replies) and draft a personalized response to real messages, then sends it as a threaded reply inside the original conversation.
|
||||
|
||||
## How email threading works
|
||||
|
||||
Every email carries a hidden `Message-ID` header — a unique fingerprint assigned by the sender's mail server. When you reply to an email, your mail client sets an `In-Reply-To` header referencing that fingerprint. That's how Gmail, Outlook, and every other client groups messages into threads.
|
||||
|
||||
In Twenty, that fingerprint is stored as `headerMessageId` on the Message object. Your workflow grabs it and passes it into the Send Email action's In-Reply-To field.
|
||||
|
||||
## Building the workflow
|
||||
|
||||
### Step 1: Create a new workflow
|
||||
|
||||
Head to **Settings -> Workflows** and click **+ New Workflow**.
|
||||
|
||||
### Step 2: Trigger on incoming messages
|
||||
|
||||
Pick **When a Record is Created** and choose **Messages**.
|
||||
|
||||
Every time an email lands in Twenty, this fires.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/workflow-overview.png" style={{width:'100%'}}/>
|
||||
|
||||
### Step 3: Look up who sent it
|
||||
|
||||
Add a **Search Records** action.
|
||||
|
||||
The sender's address isn't on the message itself — it's on the related Message Participant record.
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Object** | Message Participants |
|
||||
| **Filter** | Message **is** `{{trigger.id}}` |
|
||||
| **Filter** | Role **is** From |
|
||||
| **Limit** | 1 |
|
||||
|
||||
This gives you the sender's email in `handle` and their name in `displayName`.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/find-sender.png" style={{width:'100%'}}/>
|
||||
|
||||
### Step 4: AI triage and draft reply
|
||||
|
||||
Add an **AI Agent** action. This single step does two things: decides whether the email deserves a reply, and if so, writes one.
|
||||
|
||||
Use a prompt like:
|
||||
|
||||
```
|
||||
You are an email triage assistant for a sales team. Read the following
|
||||
inbound email and decide if it deserves a reply.
|
||||
|
||||
Subject: {{trigger.subject}}
|
||||
Body: {{trigger.text}}
|
||||
From: {{Find Sender.first.displayName}} ({{Find Sender.first.handle}})
|
||||
|
||||
If this email is spam, a newsletter, an automated notification, or
|
||||
otherwise does not need a human reply, respond with exactly: SKIP
|
||||
|
||||
Otherwise, write a short, professional reply (3-4 sentences max) that:
|
||||
- Acknowledges their specific message
|
||||
- Lets them know someone from the team will follow up shortly
|
||||
- Is warm but not overly casual
|
||||
|
||||
Respond with only the reply text, no subject line or greeting prefix.
|
||||
```
|
||||
|
||||
The AI Agent outputs its response in a `response` field that the next steps can reference.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/ai-triage.png" style={{width:'100%'}}/>
|
||||
|
||||
### Step 5: Branch on the AI decision
|
||||
|
||||
Add an **If/Else** action to check whether the AI decided to reply or skip.
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Condition** | AI Agent `response` **does not contain** `SKIP` |
|
||||
| **If true** | Continue to Send Email |
|
||||
| **Else** | Do nothing (workflow ends) |
|
||||
|
||||
Spam, newsletters, and auto-generated messages get dropped. Everything else moves to the next step.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/should-reply.png" style={{width:'100%'}}/>
|
||||
|
||||
### Step 6: Send a threaded reply
|
||||
|
||||
Add a **Send Email** action on the "if true" branch. Click **Advanced options**, then **Add In-Reply-To**.
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **To** | `{{Find Sender.first.handle}}` |
|
||||
| **Subject** | `Re: {{trigger.subject}}` |
|
||||
| **Body** | `{{AI Triage & Draft Reply.response}}` |
|
||||
| **In-Reply-To** | `{{trigger.headerMessageId}}` |
|
||||
|
||||
The In-Reply-To field is what makes this a reply instead of a new conversation. The recipient sees it threaded under the original email in Gmail, Outlook, or any other client.
|
||||
|
||||
<img src="/images/user-guide/workflows/auto-reply/send-email.png" style={{width:'100%'}}/>
|
||||
|
||||
<Tip>
|
||||
**In-Reply-To** expects a `message.headerMessageId` from the trigger — it's the email's unique fingerprint, not a recipient address. If you leave it empty, the email still sends, just as a standalone message.
|
||||
</Tip>
|
||||
|
||||
<Warning>
|
||||
Gmail uses the subject line to group messages into threads. The subject **must** start with `Re:` (including the colon and space) for Gmail to display the reply inside the original thread. Without it, the reply will appear as a separate conversation — even if the In-Reply-To header is set correctly.
|
||||
</Warning>
|
||||
|
||||
### Step 7: Test and activate
|
||||
|
||||
Hit **Test**, then check your email client. The reply should appear nested under the original message.
|
||||
|
||||
Activate when you're happy with it.
|
||||
|
||||
## Ideas to build on
|
||||
|
||||
- **Only reply to VIPs** — add a branch that checks the sender's domain or whether they exist as a Contact in Twenty
|
||||
- **Route by intent** — use separate AI Agent prompts to handle sales inquiries differently from support requests
|
||||
- **Enrich before replying** — add a Search Records step to pull the sender's company or deal history into the AI prompt for more personalized replies
|
||||
@@ -9,4 +9,5 @@ export type EmailFormData = {
|
||||
subject: string;
|
||||
body: string;
|
||||
files: WorkflowAttachment[];
|
||||
inReplyTo: string;
|
||||
};
|
||||
|
||||
@@ -75,12 +75,14 @@ export const WorkflowEditActionEmailBase = ({
|
||||
const [visibleAdvancedFields, setVisibleAdvancedFields] = useState<{
|
||||
cc: boolean;
|
||||
bcc: boolean;
|
||||
inReplyTo: boolean;
|
||||
}>(() => {
|
||||
const inputRecipients = action.settings.input.recipients;
|
||||
|
||||
return {
|
||||
cc: Boolean(inputRecipients?.cc),
|
||||
bcc: Boolean(inputRecipients?.bcc),
|
||||
inReplyTo: Boolean(action.settings.input.inReplyTo),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -89,7 +91,9 @@ export const WorkflowEditActionEmailBase = ({
|
||||
const advancedOptionsDropdownId = `${action.id}-email-advanced-options`;
|
||||
|
||||
const hasAvailableAdvancedOptions =
|
||||
!visibleAdvancedFields.cc || !visibleAdvancedFields.bcc;
|
||||
!visibleAdvancedFields.cc ||
|
||||
!visibleAdvancedFields.bcc ||
|
||||
!visibleAdvancedFields.inReplyTo;
|
||||
|
||||
const handleReauthorize = async () => {
|
||||
if (!isDefined(missingScopes)) {
|
||||
@@ -276,6 +280,18 @@ export const WorkflowEditActionEmailBase = ({
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
/>
|
||||
)}
|
||||
{visibleAdvancedFields.inReplyTo && (
|
||||
<FormTextFieldInput
|
||||
label={t`In-Reply-To`}
|
||||
placeholder={t`Enter Message-ID to reply to`}
|
||||
readonly={actionOptions.readonly}
|
||||
defaultValue={formData.inReplyTo}
|
||||
onChange={(value) => {
|
||||
handleFieldChange('inReplyTo', value);
|
||||
}}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
/>
|
||||
)}
|
||||
{!actionOptions.readonly && hasAvailableAdvancedOptions && (
|
||||
<Dropdown
|
||||
dropdownId={advancedOptionsDropdownId}
|
||||
@@ -317,6 +333,18 @@ export const WorkflowEditActionEmailBase = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!visibleAdvancedFields.inReplyTo && (
|
||||
<MenuItem
|
||||
text={t`Add In-Reply-To`}
|
||||
onClick={() => {
|
||||
setVisibleAdvancedFields((prev) => ({
|
||||
...prev,
|
||||
inReplyTo: true,
|
||||
}));
|
||||
closeDropdown(advancedOptionsDropdownId);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ const DEFAULT_SEND_EMAIL_ACTION: WorkflowSendEmailAction = {
|
||||
subject: '',
|
||||
body: '',
|
||||
files: [],
|
||||
inReplyTo: '',
|
||||
},
|
||||
outputSchema: {},
|
||||
errorHandlingOptions: {
|
||||
@@ -62,6 +63,7 @@ const CONFIGURED_SEND_EMAIL_ACTION: WorkflowSendEmailAction = {
|
||||
subject: 'Welcome to Twenty!',
|
||||
body: 'Dear Tim,\n\nWelcome to Twenty! We are excited to have you on board.\n\nBest regards,\nThe Team',
|
||||
files: [],
|
||||
inReplyTo: '',
|
||||
},
|
||||
outputSchema: {},
|
||||
errorHandlingOptions: {
|
||||
@@ -91,6 +93,7 @@ const DEFAULT_DRAFT_EMAIL_ACTION: WorkflowDraftEmailAction = {
|
||||
subject: '',
|
||||
body: '',
|
||||
files: [],
|
||||
inReplyTo: '',
|
||||
},
|
||||
outputSchema: {},
|
||||
errorHandlingOptions: {
|
||||
|
||||
@@ -28,6 +28,7 @@ export const useEmailForm = ({
|
||||
subject: action.settings.input.subject ?? '',
|
||||
body: action.settings.input.body ?? '',
|
||||
files: action.settings.input.files ?? [],
|
||||
inReplyTo: action.settings.input.inReplyTo ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -46,6 +47,7 @@ export const useEmailForm = ({
|
||||
subject: formData.subject,
|
||||
body: formData.body,
|
||||
files: formData.files,
|
||||
inReplyTo: formData.inReplyTo,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -89,6 +89,7 @@ export class DraftEmailTool implements Tool {
|
||||
body: data.plainTextBody,
|
||||
html: data.sanitizedHtmlBody,
|
||||
attachments: data.attachments,
|
||||
inReplyTo: data.inReplyTo,
|
||||
},
|
||||
data.connectedAccount,
|
||||
);
|
||||
|
||||
@@ -214,7 +214,7 @@ export class EmailComposerService {
|
||||
context: ToolExecutionContext,
|
||||
): Promise<EmailComposerResult> {
|
||||
const { workspaceId } = context;
|
||||
const { subject, body, files } = parameters;
|
||||
const { subject, body, files, inReplyTo } = parameters;
|
||||
let { connectedAccountId } = parameters;
|
||||
|
||||
let recipients: { to: string[]; cc: string[]; bcc: string[] };
|
||||
@@ -309,6 +309,7 @@ export class EmailComposerService {
|
||||
sanitizedHtmlBody,
|
||||
attachments,
|
||||
connectedAccount: connectedAccountWithFreshTokens,
|
||||
inReplyTo,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,4 +37,10 @@ export const EmailToolInputZodSchema = z.object({
|
||||
.describe('Array of file objects to attach to the email')
|
||||
.optional()
|
||||
.default([]),
|
||||
inReplyTo: z
|
||||
.string()
|
||||
.describe(
|
||||
'The RFC 2822 Message-ID of an existing email to reply to. When provided, the email is sent as a reply in the same thread.',
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -88,6 +88,7 @@ export class SendEmailTool implements Tool {
|
||||
body: data.plainTextBody,
|
||||
html: data.sanitizedHtmlBody,
|
||||
attachments: data.attachments,
|
||||
inReplyTo: data.inReplyTo,
|
||||
},
|
||||
data.connectedAccount,
|
||||
);
|
||||
|
||||
@@ -9,4 +9,5 @@ export type ComposedEmail = {
|
||||
sanitizedHtmlBody: string;
|
||||
attachments: MessageAttachment[];
|
||||
connectedAccount: ConnectedAccountWorkspaceEntity;
|
||||
inReplyTo?: string;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type MessageOutboundDriver } from 'src/modules/messaging/message-outbound-manager/interfaces/message-outbound-driver.interface';
|
||||
|
||||
import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service';
|
||||
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
import { toMicrosoftRecipients } from 'src/modules/messaging/message-import-manager/utils/to-microsoft-recipients.util';
|
||||
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
|
||||
import { type Client as MicrosoftGraphClient } from '@microsoft/microsoft-graph-client';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@Injectable()
|
||||
export class MicrosoftMessageOutboundService implements MessageOutboundDriver {
|
||||
@@ -24,13 +24,12 @@ export class MicrosoftMessageOutboundService implements MessageOutboundDriver {
|
||||
connectedAccount,
|
||||
);
|
||||
|
||||
const message = this.composeMicrosoftMessage(sendMessageInput);
|
||||
const messageId = await this.createDraftMessage(
|
||||
microsoftClient,
|
||||
sendMessageInput,
|
||||
);
|
||||
|
||||
const response = await microsoftClient.api(`/me/messages`).post(message);
|
||||
|
||||
z.string().parse(response.id);
|
||||
|
||||
await microsoftClient.api(`/me/messages/${response.id}/send`).post({});
|
||||
await microsoftClient.api(`/me/messages/${messageId}/send`).post({});
|
||||
}
|
||||
|
||||
async createDraft(
|
||||
@@ -42,9 +41,50 @@ export class MicrosoftMessageOutboundService implements MessageOutboundDriver {
|
||||
connectedAccount,
|
||||
);
|
||||
|
||||
await this.createDraftMessage(microsoftClient, sendMessageInput);
|
||||
}
|
||||
|
||||
private async createDraftMessage(
|
||||
microsoftClient: MicrosoftGraphClient,
|
||||
sendMessageInput: SendMessageInput,
|
||||
): Promise<string> {
|
||||
const parentMessageGraphId = sendMessageInput.inReplyTo
|
||||
? await this.findMessageByInternetMessageId(
|
||||
microsoftClient,
|
||||
sendMessageInput.inReplyTo,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const message = this.composeMicrosoftMessage(sendMessageInput);
|
||||
|
||||
await microsoftClient.api(`/me/messages`).post(message);
|
||||
if (isDefined(parentMessageGraphId)) {
|
||||
const reply = await microsoftClient
|
||||
.api(`/me/messages/${parentMessageGraphId}/createReply`)
|
||||
.post({});
|
||||
|
||||
await microsoftClient.api(`/me/messages/${reply.id}`).patch(message);
|
||||
|
||||
return reply.id;
|
||||
}
|
||||
|
||||
const response = await microsoftClient.api('/me/messages').post(message);
|
||||
|
||||
return response.id;
|
||||
}
|
||||
|
||||
private async findMessageByInternetMessageId(
|
||||
microsoftClient: MicrosoftGraphClient,
|
||||
internetMessageId: string,
|
||||
): Promise<string | undefined> {
|
||||
const encodedId = encodeURIComponent(internetMessageId);
|
||||
|
||||
const response = await microsoftClient
|
||||
.api(
|
||||
`/me/messages?$filter=internetMessageId eq '${encodedId}'&$select=id&$top=1`,
|
||||
)
|
||||
.get();
|
||||
|
||||
return response?.value?.[0]?.id;
|
||||
}
|
||||
|
||||
private composeMicrosoftMessage(
|
||||
|
||||
@@ -12,4 +12,5 @@ export type SendMessageInput = {
|
||||
content: Buffer;
|
||||
contentType: string;
|
||||
}[];
|
||||
inReplyTo?: string;
|
||||
};
|
||||
|
||||
@@ -21,5 +21,11 @@ export const toMailComposerOptions = (
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
...(sendMessageInput.inReplyTo
|
||||
? {
|
||||
inReplyTo: sendMessageInput.inReplyTo,
|
||||
references: sendMessageInput.inReplyTo,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,4 +5,5 @@ export type WorkflowSendEmailActionInput = {
|
||||
recipients: EmailRecipients;
|
||||
subject?: string;
|
||||
body?: string;
|
||||
inReplyTo?: string;
|
||||
};
|
||||
|
||||
@@ -272,6 +272,8 @@ export const DOCUMENTATION_PATHS = {
|
||||
'/user-guide/workflows/how-tos/connect-to-other-tools/generate-quote-or-invoice-from-twenty',
|
||||
USER_GUIDE_WORKFLOWS_HOW_TOS_CONNECT_TO_OTHER_TOOLS_SET_UP_A_WEBHOOK_TRIGGER:
|
||||
'/user-guide/workflows/how-tos/connect-to-other-tools/set-up-a-webhook-trigger',
|
||||
USER_GUIDE_WORKFLOWS_HOW_TOS_CRM_AUTOMATIONS_AUTO_REPLY_TO_INBOUND_EMAILS:
|
||||
'/user-guide/workflows/how-tos/crm-automations/auto-reply-to-inbound-emails',
|
||||
USER_GUIDE_WORKFLOWS_HOW_TOS_CRM_AUTOMATIONS_CLOSED_WON_AUTOMATIONS:
|
||||
'/user-guide/workflows/how-tos/crm-automations/closed-won-automations',
|
||||
USER_GUIDE_WORKFLOWS_HOW_TOS_CRM_AUTOMATIONS_DETECT_STALE_OPPORTUNITIES:
|
||||
|
||||
@@ -14,5 +14,6 @@ export const workflowSendEmailActionSettingsSchema =
|
||||
subject: z.string().optional(),
|
||||
body: z.string().optional(),
|
||||
files: z.array(workflowFileSchema).optional().default([]),
|
||||
inReplyTo: z.string().trim().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||