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:
+6
-5
@@ -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({
|
||||
|
||||
+2
@@ -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;
|
||||
};
|
||||
|
||||
+70
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+42
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user