Compare commits

...

2 Commits

Author SHA1 Message Date
Félix Malfait b82ae2bb8b Clean up email body handling: explicit HTML contract, no format guessing
The email tool's composeEmail service now always receives HTML body.
Workflow actions render their TipTap JSONContent to HTML at the boundary
via a new renderRichTextToHtml utility, instead of relying on magic
JSON.parse detection inside the shared service.

- Delete parseEmailBody (format-guessing utility)
- Simplify email-composer.service to sanitize + plain-text only
- Move JSONContent rendering to workflow action boundary
- Update tool schema to describe body as HTML format

Made-with: Cursor
2026-04-03 19:16:01 +02:00
Félix Malfait 6e206bc29b Remove IS_DRAFT_EMAIL_ENABLED feature flag, always request gmail.compose scope
The gmail.compose OAuth scope was conditionally requested based on the
IS_DRAFT_EMAIL_ENABLED feature flag, which meant email draft creation
silently failed for workspaces where the flag was off (the default).
This removes the feature flag entirely and always requests the
gmail.compose scope alongside gmail.send.

Made-with: Cursor
2026-04-03 19:04:19 +02:00
21 changed files with 39 additions and 162 deletions
@@ -1728,7 +1728,6 @@ enum FeatureFlagKey {
IS_PUBLIC_DOMAIN_ENABLED
IS_EMAILING_DOMAIN_ENABLED
IS_JUNCTION_RELATIONS_ENABLED
IS_DRAFT_EMAIL_ENABLED
IS_USAGE_ANALYTICS_ENABLED
IS_RICH_TEXT_V1_MIGRATED
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED
@@ -1424,7 +1424,7 @@ export interface PublicFeatureFlag {
__typename: 'PublicFeatureFlag'
}
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_AI_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_MARKETPLACE_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_DRAFT_EMAIL_ENABLED' | 'IS_USAGE_ANALYTICS_ENABLED' | 'IS_RICH_TEXT_V1_MIGRATED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_CONNECTED_ACCOUNT_MIGRATED' | 'IS_RECORD_TABLE_WIDGET_ENABLED' | 'IS_DATASOURCE_MIGRATED'
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_AI_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_MARKETPLACE_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_USAGE_ANALYTICS_ENABLED' | 'IS_RICH_TEXT_V1_MIGRATED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_CONNECTED_ACCOUNT_MIGRATED' | 'IS_RECORD_TABLE_WIDGET_ENABLED' | 'IS_DATASOURCE_MIGRATED'
export interface ClientConfigMaintenanceMode {
startAt: Scalars['DateTime']
@@ -9096,7 +9096,6 @@ export const enumFeatureFlagKey = {
IS_PUBLIC_DOMAIN_ENABLED: 'IS_PUBLIC_DOMAIN_ENABLED' as const,
IS_EMAILING_DOMAIN_ENABLED: 'IS_EMAILING_DOMAIN_ENABLED' as const,
IS_JUNCTION_RELATIONS_ENABLED: 'IS_JUNCTION_RELATIONS_ENABLED' as const,
IS_DRAFT_EMAIL_ENABLED: 'IS_DRAFT_EMAIL_ENABLED' as const,
IS_USAGE_ANALYTICS_ENABLED: 'IS_USAGE_ANALYTICS_ENABLED' as const,
IS_RICH_TEXT_V1_MIGRATED: 'IS_RICH_TEXT_V1_MIGRATED' as const,
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED: 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' as const,
@@ -1724,7 +1724,6 @@ export enum FeatureFlagKey {
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
IS_CONNECTED_ACCOUNT_MIGRATED = 'IS_CONNECTED_ACCOUNT_MIGRATED',
IS_DATASOURCE_MIGRATED = 'IS_DATASOURCE_MIGRATED',
IS_DRAFT_EMAIL_ENABLED = 'IS_DRAFT_EMAIL_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
@@ -27,9 +27,6 @@ export const SidePanelWorkflowSelectAction = ({
onActionSelected: (selection: WorkflowActionSelection) => void;
}) => {
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const isDraftEmailEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_DRAFT_EMAIL_ENABLED,
);
const { t } = useLingui();
@@ -37,10 +34,6 @@ export const SidePanelWorkflowSelectAction = ({
const toolFunctions = logicFunctions.filter((fn) => fn.isTool === true);
const coreActions = isDraftEmailEnabled
? CORE_ACTIONS
: CORE_ACTIONS.filter((action) => action.type !== 'DRAFT_EMAIL');
const handleActionClick = (actionType: WorkflowActionType) => {
onActionSelected({ type: actionType });
};
@@ -88,7 +81,7 @@ export const SidePanelWorkflowSelectAction = ({
{t`Core`}
</SidePanelWorkflowSelectStepTitle>
<WorkflowActionMenuItems
actions={coreActions}
actions={CORE_ACTIONS}
onClick={handleActionClick}
/>
@@ -3,7 +3,6 @@ import { AuthGuard } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FeatureFlagKey } from 'twenty-shared/types';
import {
AuthException,
@@ -13,7 +12,6 @@ import { GoogleAPIsOauthExchangeCodeForTokenStrategy } from 'src/engine/core-mod
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -29,7 +27,6 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly featureFlagService: FeatureFlagService,
) {
super();
}
@@ -54,15 +51,8 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
state.transientToken,
);
const isDraftEmailEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_DRAFT_EMAIL_ENABLED,
workspaceId,
);
new GoogleAPIsOauthExchangeCodeForTokenStrategy(
this.twentyConfigService,
isDraftEmailEnabled,
);
setRequestExtraParams(request, {
@@ -3,7 +3,6 @@ import { AuthGuard } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FeatureFlagKey } from 'twenty-shared/types';
import {
AuthException,
@@ -13,7 +12,6 @@ import { GoogleAPIsOauthRequestCodeStrategy } from 'src/engine/core-modules/auth
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -27,7 +25,6 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly featureFlagService: FeatureFlagService,
) {
super({
prompt: 'select_account',
@@ -71,16 +68,7 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
);
}
const isDraftEmailEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_DRAFT_EMAIL_ENABLED,
workspaceId,
);
new GoogleAPIsOauthRequestCodeStrategy(
this.twentyConfigService,
isDraftEmailEnabled,
);
new GoogleAPIsOauthRequestCodeStrategy(this.twentyConfigService);
return (await super.canActivate(context)) as boolean;
} catch (err) {
@@ -30,7 +30,6 @@ export class GoogleAPIScopesService {
public async getScopesFromGoogleAccessTokenAndCheckIfExpectedScopesArePresent(
accessToken: string,
isDraftEmailEnabled = false,
): Promise<{ scopes: string[]; isValid: boolean }> {
try {
const httpClient = this.secureHttpClientService.getHttpClient();
@@ -41,7 +40,7 @@ export class GoogleAPIScopesService {
);
const scopes = response.data.scope.split(' ');
const expectedScopes = getGoogleApisOauthScopes(isDraftEmailEnabled);
const expectedScopes = getGoogleApisOauthScopes();
return {
scopes,
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { ConnectedAccountProvider, FeatureFlagKey } from 'twenty-shared/types';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { v4 } from 'uuid';
import {
@@ -13,7 +13,6 @@ import { CreateMessageChannelService } from 'src/engine/core-modules/auth/servic
import { GoogleAPIScopesService } from 'src/engine/core-modules/auth/services/google-apis-scopes';
import { GoogleApisServiceAvailabilityService } from 'src/engine/core-modules/auth/services/google-apis-service-availability.service';
import { UpdateConnectedAccountOnReconnectService } from 'src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@@ -65,7 +64,6 @@ export class GoogleAPIsService {
private readonly updateConnectedAccountOnReconnectService: UpdateConnectedAccountOnReconnectService,
private readonly googleAPIScopesService: GoogleAPIScopesService,
private readonly googleApisServiceAvailabilityService: GoogleApisServiceAvailabilityService,
private readonly featureFlagService: FeatureFlagService,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
private readonly messageChannelDataAccessService: MessageChannelDataAccessService,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
@@ -98,15 +96,9 @@ export class GoogleAPIsService {
'MESSAGING_PROVIDER_GMAIL_ENABLED',
);
const isDraftEmailEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_DRAFT_EMAIL_ENABLED,
workspaceId,
);
const { scopes, isValid } =
await this.googleAPIScopesService.getScopesFromGoogleAccessTokenAndCheckIfExpectedScopesArePresent(
input.accessToken,
isDraftEmailEnabled,
);
if (!isValid) {
@@ -14,11 +14,8 @@ export abstract class GoogleAPIsOauthCommonStrategy extends PassportStrategy(
Strategy,
'google-apis',
) {
constructor(
twentyConfigService: TwentyConfigService,
isDraftEmailEnabled = false,
) {
const scopes = getGoogleApisOauthScopes(isDraftEmailEnabled);
constructor(twentyConfigService: TwentyConfigService) {
const scopes = getGoogleApisOauthScopes();
super({
clientID: twentyConfigService.get('AUTH_GOOGLE_CLIENT_ID'),
@@ -13,11 +13,8 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
@Injectable()
export class GoogleAPIsOauthExchangeCodeForTokenStrategy extends GoogleAPIsOauthCommonStrategy {
constructor(
twentyConfigService: TwentyConfigService,
isDraftEmailEnabled = false,
) {
super(twentyConfigService, isDraftEmailEnabled);
constructor(twentyConfigService: TwentyConfigService) {
super(twentyConfigService);
}
async validate(
@@ -12,11 +12,8 @@ export type GoogleAPIScopeConfig = {
@Injectable()
export class GoogleAPIsOauthRequestCodeStrategy extends GoogleAPIsOauthCommonStrategy {
constructor(
twentyConfigService: TwentyConfigService,
isDraftEmailEnabled = false,
) {
super(twentyConfigService, isDraftEmailEnabled);
constructor(twentyConfigService: TwentyConfigService) {
super(twentyConfigService);
}
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
@@ -1,7 +1,7 @@
/** email, profile and openid permission can be called without the https://www.googleapis.com/auth/ prefix
* see https://developers.google.com/identity/protocols/oauth2/scopes
*/
export const getGoogleApisOauthScopes = (isDraftEmailEnabled = false) => {
export const getGoogleApisOauthScopes = () => {
return [
'email',
'profile',
@@ -9,8 +9,6 @@ export const getGoogleApisOauthScopes = (isDraftEmailEnabled = false) => {
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/profile.emails.read',
'https://www.googleapis.com/auth/gmail.send',
...(isDraftEmailEnabled
? ['https://www.googleapis.com/auth/gmail.compose']
: []),
'https://www.googleapis.com/auth/gmail.compose',
];
};
@@ -1,9 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { render, toPlainText } from '@react-email/render';
import { toPlainText } from '@react-email/render';
import DOMPurify from 'dompurify';
import { reactMarkupFromJSON } from 'twenty-emails';
import { FileFolder } from 'twenty-shared/types';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { WorkflowAttachment } from 'twenty-shared/workflow';
@@ -19,14 +18,13 @@ import {
import { EmailComposerResult } from 'src/engine/core-modules/tool/tools/email-tool/types/email-composer-result.type';
import { EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/email-tool/utils/parse-comma-separated-emails.util';
import { ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool.type';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool.type';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { MessagingAccountAuthenticationService } from 'src/modules/messaging/message-import-manager/services/messaging-account-authentication.service';
import { type MessageAttachment } from 'src/modules/messaging/message-import-manager/types/message';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { parseEmailBody } from 'src/utils/parse-email-body';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@Injectable()
export class EmailComposerService {
@@ -288,15 +286,12 @@ export class EmailComposerService {
const attachments = await this.getAttachments(files || [], workspaceId);
const parsedBody = parseEmailBody(body);
const reactMarkup = reactMarkupFromJSON(parsedBody);
const htmlBody = await render(reactMarkup);
const plainTextBody = toPlainText(htmlBody);
const { JSDOM } = await import('jsdom');
const window = new JSDOM('').window;
const purify = DOMPurify(window);
const sanitizedHtmlBody = purify.sanitize(htmlBody || '');
const sanitizedHtmlBody = purify.sanitize(body || '');
const plainTextBody = toPlainText(sanitizedHtmlBody);
const sanitizedSubject = purify.sanitize(subject || '');
return {
@@ -24,7 +24,7 @@ export const EmailToolInputZodSchema = z.object({
'Recipients object with to, cc, and bcc fields (comma-separated)',
),
subject: z.string().describe('The email subject line'),
body: z.string().describe('The email body content (HTML or plain text)'),
body: z.string().describe('The email body content in HTML format'),
connectedAccountId: z
.string()
.refine((val) => isValidUuid(val))
@@ -0,0 +1,10 @@
import { render } from '@react-email/render';
import { type JSONContent, reactMarkupFromJSON } from 'twenty-emails';
export const renderRichTextToHtml = async (
jsonContent: JSONContent,
): Promise<string> => {
const reactMarkup = reactMarkupFromJSON(jsonContent);
return render(reactMarkup);
};
@@ -238,7 +238,6 @@ describe('WorkspaceEntityManager', () => {
IS_PUBLIC_DOMAIN_ENABLED: false,
IS_EMAILING_DOMAIN_ENABLED: false,
IS_JUNCTION_RELATIONS_ENABLED: false,
IS_DRAFT_EMAIL_ENABLED: false,
IS_USAGE_ANALYTICS_ENABLED: false,
IS_RICH_TEXT_V1_MIGRATED: false,
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED: false,
@@ -5,8 +5,9 @@ import { resolveInput, resolveRichTextVariables } from 'twenty-shared/utils';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool';
import { renderRichTextToHtml } from 'src/engine/core-modules/tool/tools/email-tool/utils/render-rich-text-to-html.util';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import {
@@ -67,9 +68,16 @@ export class ToolExecutorWorkflowAction implements WorkflowAction {
const emailInput = toolInput as WorkflowSendEmailActionInput;
if (emailInput.body) {
const resolvedBody = resolveRichTextVariables(
emailInput.body,
context,
);
const bodyJson = JSON.parse(resolvedBody);
const htmlBody = await renderRichTextToHtml(bodyJson);
toolInput = {
...emailInput,
body: resolveRichTextVariables(emailInput.body, context),
body: htmlBody,
};
}
}
@@ -1,71 +0,0 @@
import { parseEmailBody } from 'src/utils/parse-email-body';
describe('parseEmailBody', () => {
it('should parse valid JSON content', () => {
const jsonContent = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Hello World' }],
},
],
};
const result = parseEmailBody(JSON.stringify(jsonContent));
expect(result).toEqual(jsonContent);
});
it('should return plain string when JSON parsing fails', () => {
const plainText = 'This is plain text, not JSON';
const result = parseEmailBody(plainText);
expect(result).toBe(plainText);
});
it('should parse JSON content with hardBreak nodes', () => {
const jsonWithHardBreaks = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Line 1' },
{ type: 'hardBreak' },
{ type: 'text', text: 'Line 2' },
],
},
],
};
const result = parseEmailBody(JSON.stringify(jsonWithHardBreaks));
expect(result).toEqual(jsonWithHardBreaks);
expect(
(result as typeof jsonWithHardBreaks).content[0].content,
).toContainEqual({
type: 'hardBreak',
});
});
it('should handle empty string', () => {
const result = parseEmailBody('');
expect(result).toBe('');
});
it('should handle JSON array format', () => {
const arrayContent = [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Content' }],
},
];
const result = parseEmailBody(JSON.stringify(arrayContent));
expect(result).toEqual(arrayContent);
});
});
@@ -1,11 +0,0 @@
import { type JSONContent } from 'twenty-emails';
export const parseEmailBody = (body: string): JSONContent | string => {
try {
const json = JSON.parse(body);
return json;
} catch {
return body;
}
};
@@ -8,7 +8,6 @@ export enum FeatureFlagKey {
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_DRAFT_EMAIL_ENABLED = 'IS_DRAFT_EMAIL_ENABLED',
IS_USAGE_ANALYTICS_ENABLED = 'IS_USAGE_ANALYTICS_ENABLED',
IS_RICH_TEXT_V1_MIGRATED = 'IS_RICH_TEXT_V1_MIGRATED',
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED = 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED',