Support variables file email attachment (#21613)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21613?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
neo773
2026-06-16 21:41:37 +05:30
committed by GitHub
parent d8d5991977
commit 1ad919955a
15 changed files with 256 additions and 60 deletions
@@ -9,6 +9,7 @@ import {
type EmailStepLogMode,
} from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/build-email-step-log.util';
import { resolveEmailBody } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/resolve-email-body.util';
import { resolveEmailFiles } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/resolve-email-files.util';
import { ToolBackedWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/tool-backed/tool-backed.workflow-action';
export abstract class EmailWorkflowActionBase extends ToolBackedWorkflowAction<WorkflowSendEmailActionInput> {
@@ -18,13 +19,13 @@ export abstract class EmailWorkflowActionBase extends ToolBackedWorkflowAction<W
rawInput: WorkflowSendEmailActionInput,
context: Record<string, unknown>,
): Promise<WorkflowSendEmailActionInput> {
if (!isDefined(rawInput.body)) {
return rawInput;
}
const files = resolveEmailFiles(rawInput.files, context);
const renderedBody = await resolveEmailBody(rawInput.body, context);
const body = isDefined(rawInput.body)
? await resolveEmailBody(rawInput.body, context)
: rawInput.body;
return { ...rawInput, body: renderedBody };
return { ...rawInput, body, files };
}
protected buildStepLog({
@@ -1,3 +1,4 @@
import { type EmailAttachment } from 'twenty-shared/types';
import { type EmailRecipients } from 'twenty-shared/workflow';
export type WorkflowSendEmailActionInput = {
@@ -5,5 +6,6 @@ export type WorkflowSendEmailActionInput = {
recipients: EmailRecipients;
subject?: string;
body?: string;
files?: EmailAttachment[];
inReplyTo?: string;
};
@@ -0,0 +1,70 @@
import { resolveEmailFiles } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/utils/resolve-email-files.util';
describe('resolveEmailFiles', () => {
it('conforms static uploads to id/name, dropping extra metadata', () => {
const upload = {
id: 'file-1',
name: 'logo.png',
size: 1024,
type: 'image/png',
createdAt: '2026-01-01T00:00:00.000Z',
};
expect(resolveEmailFiles([upload], {})).toEqual([
{ id: 'file-1', name: 'logo.png' },
]);
});
it('resolves a variable bound to a record Files field and flattens it', () => {
const context = {
step: {
first: {
files: [
{ fileId: 'file-1', label: 'Contract', extension: '.pdf' },
{ fileId: 'file-2', label: 'Proposal', extension: '.pdf' },
],
},
},
};
expect(resolveEmailFiles(['{{step.first.files}}'], context)).toEqual([
{ id: 'file-1', name: 'Contract.pdf' },
{ id: 'file-2', name: 'Proposal.pdf' },
]);
});
it('mixes a static upload with a resolved record Files variable', () => {
const context = {
step: { files: [{ fileId: 'file-2', label: 'Deck', extension: '.pdf' }] },
};
expect(
resolveEmailFiles(
[{ id: 'file-1', name: 'brochure.pdf' }, '{{step.files}}'],
context,
),
).toEqual([
{ id: 'file-1', name: 'brochure.pdf' },
{ id: 'file-2', name: 'Deck.pdf' },
]);
});
it('falls back to the file id when no usable name is present', () => {
expect(resolveEmailFiles([{ id: 'file-1' }], {})).toEqual([
{ id: 'file-1', name: 'file-1' },
]);
});
it('returns an empty array when there is nothing to attach', () => {
expect(resolveEmailFiles(undefined, {})).toEqual([]);
expect(resolveEmailFiles([], {})).toEqual([]);
});
it('skips entries that resolve to nothing or lack a file id', () => {
const context = { step: {} };
expect(
resolveEmailFiles(['{{step.missing}}', { name: 'no-id.pdf' }], context),
).toEqual([]);
});
});
@@ -0,0 +1,42 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type EmailAttachment } from 'twenty-shared/types';
import { isDefined, resolveInput } from 'twenty-shared/utils';
import { type WorkflowAttachment } from 'twenty-shared/workflow';
import { type FileOutput } from 'src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type';
export const resolveEmailFiles = (
files: unknown,
context: Record<string, unknown>,
): EmailAttachment[] => {
const resolvedFiles = resolveInput(files, context);
if (!Array.isArray(resolvedFiles)) {
return [];
}
return resolvedFiles
.flat()
.map((file): EmailAttachment | undefined => {
if (!isDefined(file) || typeof file !== 'object') {
return undefined;
}
const attachment = file as WorkflowAttachment | FileOutput;
const id = 'id' in attachment ? attachment.id : attachment.fileId;
if (!isNonEmptyString(id)) {
return undefined;
}
const name =
'id' in attachment
? attachment.name
: [attachment.label, attachment.extension]
.filter(isNonEmptyString)
.join('');
return { id, name: isNonEmptyString(name) ? name : id };
})
.filter(isDefined);
};