feat(core): Add "Sent by n8n" attribution (#7183)

Github issue / Community forum post (link here to close automatically):

---------

Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
This commit is contained in:
Michael Kret 2023-10-03 11:18:59 +03:00 committed by GitHub
parent f0a66873b9
commit 8f9fe6269b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 345 additions and 57 deletions

View file

@ -143,7 +143,7 @@ import {
setWorkflowExecutionMetadata,
} from './WorkflowExecutionMetadata';
import { getSecretsProxy } from './Secrets';
import { getUserN8nFolderPath } from './UserSettings';
import { getUserN8nFolderPath, getInstanceId } from './UserSettings';
import Container from 'typedi';
import type { BinaryData } from './BinaryData/types';
@ -2506,6 +2506,7 @@ const getCommonWorkflowFunctions = (
getRestApiUrl: () => additionalData.restApiUrl,
getInstanceBaseUrl: () => additionalData.instanceBaseUrl,
getInstanceId: async () => getInstanceId(),
getTimezone: () => getTimezone(workflow, additionalData),
prepareOutputData: async (outputData) => [outputData],

View file

@ -103,6 +103,7 @@ export const FILTER_NODE_TYPE = 'n8n-nodes-base.filter';
export const FUNCTION_NODE_TYPE = 'n8n-nodes-base.function';
export const GITHUB_TRIGGER_NODE_TYPE = 'n8n-nodes-base.githubTrigger';
export const GIT_NODE_TYPE = 'n8n-nodes-base.git';
export const GOOGLE_GMAIL_NODE_TYPE = 'n8n-nodes-base.gmail';
export const GOOGLE_SHEETS_NODE_TYPE = 'n8n-nodes-base.googleSheets';
export const ERROR_TRIGGER_NODE_TYPE = 'n8n-nodes-base.errorTrigger';
export const ELASTIC_SECURITY_NODE_TYPE = 'n8n-nodes-base.elasticSecurity';
@ -136,6 +137,7 @@ export const SPREADSHEET_FILE_NODE_TYPE = 'n8n-nodes-base.spreadsheetFile';
export const SPLIT_IN_BATCHES_NODE_TYPE = 'n8n-nodes-base.splitInBatches';
export const START_NODE_TYPE = 'n8n-nodes-base.start';
export const SWITCH_NODE_TYPE = 'n8n-nodes-base.switch';
export const TELEGRAM_NODE_TYPE = 'n8n-nodes-base.telegram';
export const THE_HIVE_TRIGGER_NODE_TYPE = 'n8n-nodes-base.theHiveTrigger';
export const QUICKBOOKS_NODE_TYPE = 'n8n-nodes-base.quickbooks';
export const WAIT_NODE_TYPE = 'n8n-nodes-base.wait';
@ -624,3 +626,5 @@ export const nonExistingJsonPath = '_!^&*';
export const ASK_AI_MAX_PROMPT_LENGTH = 600;
export const ASK_AI_MIN_PROMPT_LENGTH = 15;
export const ASK_AI_LOADING_DURATION_MS = 12000;
export const APPEND_ATTRIBUTION_DEFAULT_PATH = 'parameters.options.appendAttribution';

View file

@ -7,7 +7,12 @@ import type { IUserNodesPanelSession } from './telemetry.types';
import { useSettingsStore } from '@/stores/settings.store';
import { useRootStore } from '@/stores/n8nRoot.store';
import { useTelemetryStore } from '@/stores/telemetry.store';
import { SLACK_NODE_TYPE } from '@/constants';
import {
APPEND_ATTRIBUTION_DEFAULT_PATH,
MICROSOFT_TEAMS_NODE_TYPE,
SLACK_NODE_TYPE,
TELEGRAM_NODE_TYPE,
} from '@/constants';
import { usePostHog } from '@/stores/posthog.store';
import { useNDVStore } from '@/stores';
@ -230,22 +235,21 @@ export class Telemetry {
// so we are using this method as centralized way to track node parameters changes
trackNodeParametersValuesChange(nodeType: string, change: IUpdateInformation) {
if (this.rudderStack) {
switch (nodeType) {
case SLACK_NODE_TYPE:
if (change.name === 'parameters.otherOptions.includeLinkToWorkflow') {
this.track(
'User toggled n8n reference option',
{
node: nodeType,
toValue: change.value,
},
{ withPostHog: true },
);
}
break;
default:
break;
const changeNameMap: { [key: string]: string } = {
[SLACK_NODE_TYPE]: 'parameters.otherOptions.includeLinkToWorkflow',
[MICROSOFT_TEAMS_NODE_TYPE]: 'parameters.options.includeLinkToWorkflow',
[TELEGRAM_NODE_TYPE]: 'parameters.additionalFields.appendAttribution',
};
const changeName = changeNameMap[nodeType] || APPEND_ATTRIBUTION_DEFAULT_PATH;
if (change.name === changeName) {
this.track(
'User toggled n8n reference option',
{
node: nodeType,
toValue: change.value,
},
{ withPostHog: true },
);
}
}
}

View file

@ -11,13 +11,14 @@ export class EmailSend extends VersionedNodeType {
name: 'emailSend',
icon: 'fa:envelope',
group: ['output'],
defaultVersion: 2,
defaultVersion: 2.1,
description: 'Sends an email using SMTP protocol',
};
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
1: new EmailSendV1(baseDescription),
2: new EmailSendV2(baseDescription),
2.1: new EmailSendV2(baseDescription),
};
super(nodeVersions, baseDescription);

View file

@ -15,7 +15,7 @@ const versionDescription: INodeTypeDescription = {
name: 'emailSend',
icon: 'fa:envelope',
group: ['output'],
version: 2,
version: [2, 2.1],
description: 'Sends an email using SMTP protocol',
defaults: {
name: 'Send Email',

View file

@ -43,6 +43,31 @@ const properties: INodeProperties[] = [
placeholder: 'My subject line',
description: 'Subject line of the email',
},
{
displayName: 'Email Format',
name: 'emailFormat',
type: 'options',
options: [
{
name: 'Text',
value: 'text',
},
{
name: 'HTML',
value: 'html',
},
{
name: 'Both',
value: 'both',
},
],
default: 'html',
displayOptions: {
hide: {
'@version': [2],
},
},
},
{
displayName: 'Email Format',
name: 'emailFormat',
@ -62,6 +87,11 @@ const properties: INodeProperties[] = [
},
],
default: 'text',
displayOptions: {
show: {
'@version': [2],
},
},
},
{
displayName: 'Text',
@ -100,6 +130,15 @@ const properties: INodeProperties[] = [
placeholder: 'Add Option',
default: {},
options: [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
displayName: 'Append n8n Attribution',
name: 'appendAttribution',
type: 'boolean',
default: true,
description:
'Whether to include the phrase “This email was sent automatically with n8n” to the end of the email',
},
{
displayName: 'Attachments',
name: 'attachments',
@ -153,6 +192,7 @@ const displayOptions = {
export const description = updateDisplayOptions(displayOptions, properties);
type EmailSendOptions = {
appendAttribution?: boolean;
allowUnauthorizedCerts?: boolean;
attachments?: string;
ccEmail?: string;
@ -185,6 +225,8 @@ function configureTransport(credentials: IDataObject, options: EmailSendOptions)
export async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const nodeVersion = this.getNode().typeVersion;
const instanceId = await this.getInstanceId();
const returnData: INodeExecutionData[] = [];
let item: INodeExecutionData;
@ -220,6 +262,32 @@ export async function execute(this: IExecuteFunctions): Promise<INodeExecutionDa
mailOptions.html = this.getNodeParameter('html', itemIndex, '');
}
let appendAttribution = options.appendAttribution;
if (appendAttribution === undefined) {
appendAttribution = nodeVersion >= 2.1;
}
if (appendAttribution) {
const attributionText = 'This email was sent automatically with ';
const link = `https://n8n.io/?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=${encodeURIComponent(
'n8n-nodes-base.emailSend',
)}${instanceId ? '_' + instanceId : ''}`;
if (emailFormat === 'html' || (emailFormat === 'both' && mailOptions.html)) {
mailOptions.html = `
${mailOptions.html}
<br>
<br>
---
<br>
<em>${attributionText}<a href="${link}" target="_blank">n8n</a></em>
`;
} else {
mailOptions.text = `${
mailOptions.text
}\n\n---\n${attributionText}n8n\n${'https://n8n.io'}`;
}
}
if (options.attachments && item.binary) {
const attachments = [];
const attachmentProperties: string[] = options.attachments

View file

@ -435,19 +435,43 @@ export function prepareEmailsInput(
export function prepareEmailBody(
this: IExecuteFunctions | ILoadOptionsFunctions,
itemIndex: number,
appendAttribution = false,
instanceId?: string,
) {
const emailType = this.getNodeParameter('emailType', itemIndex) as string;
let message = (this.getNodeParameter('message', itemIndex, '') as string).trim();
let body = '';
let htmlBody = '';
if (emailType === 'html') {
htmlBody = (this.getNodeParameter('message', itemIndex, '') as string).trim();
} else {
body = (this.getNodeParameter('message', itemIndex, '') as string).trim();
if (appendAttribution) {
const attributionText = 'This email was sent automatically with ';
const link = `https://n8n.io/?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=${encodeURIComponent(
'n8n-nodes-base.gmail',
)}${instanceId ? '_' + instanceId : ''}`;
if (emailType === 'html') {
message = `
${message}
<br>
<br>
---
<br>
<em>${attributionText}<a href="${link}" target="_blank">n8n</a></em>
`;
} else {
message = `${message}\n\n---\n${attributionText}n8n\n${'https://n8n.io'}`;
}
}
return { body, htmlBody };
const body = {
body: '',
htmlBody: '',
};
if (emailType === 'html') {
body.htmlBody = message;
} else {
body.body = message;
}
return body;
}
export async function prepareEmailAttachments(

View file

@ -13,12 +13,13 @@ export class Gmail extends VersionedNodeType {
group: ['transform'],
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume the Gmail API',
defaultVersion: 2,
defaultVersion: 2.1,
};
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
1: new GmailV1(baseDescription),
2: new GmailV2(baseDescription),
2.1: new GmailV2(baseDescription),
};
super(nodeVersions, baseDescription);

View file

@ -39,7 +39,7 @@ const versionDescription: INodeTypeDescription = {
name: 'gmail',
icon: 'file:gmail.svg',
group: ['transform'],
version: 2,
version: [2, 2.1],
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume the Gmail API',
defaults: {
@ -205,6 +205,8 @@ export class GmailV2 implements INodeType {
const returnData: INodeExecutionData[] = [];
const resource = this.getNodeParameter('resource', 0);
const operation = this.getNodeParameter('operation', 0);
const nodeVersion = this.getNode().typeVersion;
const instanceId = await this.getInstanceId();
let responseData;
@ -323,6 +325,11 @@ export class GmailV2 implements INodeType {
from = `${options.senderName as string} <${emailAddress}>`;
}
let appendAttribution = options.appendAttribution;
if (appendAttribution === undefined) {
appendAttribution = nodeVersion >= 2.1;
}
const email: IEmail = {
from,
to,
@ -330,7 +337,7 @@ export class GmailV2 implements INodeType {
bcc,
replyTo,
subject: this.getNodeParameter('subject', i) as string,
...prepareEmailBody.call(this, i),
...prepareEmailBody.call(this, i, appendAttribution as boolean, instanceId),
attachments,
};

View file

@ -125,7 +125,7 @@ export const messageFields: INodeProperties[] = [
displayName: 'Email Type',
name: 'emailType',
type: 'options',
default: 'text',
default: 'html',
required: true,
noDataExpression: true,
options: [
@ -143,6 +143,34 @@ export const messageFields: INodeProperties[] = [
resource: ['message'],
operation: ['send', 'reply'],
},
hide: {
'@version': [2],
},
},
},
{
displayName: 'Email Type',
name: 'emailType',
type: 'options',
default: 'html',
required: true,
noDataExpression: true,
options: [
{
name: 'Text',
value: 'text',
},
{
name: 'HTML',
value: 'html',
},
],
displayOptions: {
show: {
resource: ['message'],
operation: ['send', 'reply'],
'@version': [2],
},
},
},
{
@ -171,6 +199,15 @@ export const messageFields: INodeProperties[] = [
},
default: {},
options: [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
displayName: 'Append n8n Attribution',
name: 'appendAttribution',
type: 'boolean',
default: true,
description:
'Whether to include the phrase “This email was sent automatically with n8n” to the end of the email',
},
{
displayName: 'Attachments',
name: 'attachmentsUi',

View file

@ -120,6 +120,14 @@ export const channelMessageFields: INodeProperties[] = [
},
},
options: [
{
displayName: 'Include Link to Workflow',
name: 'includeLinkToWorkflow',
type: 'boolean',
default: true,
description:
'Whether to append a link to this workflow at the end of the message. This is helpful if you have many workflows sending messages.',
},
{
displayName: 'Make Reply',
name: 'makeReply',

View file

@ -95,6 +95,30 @@ export const chatMessageFields: INodeProperties[] = [
default: '',
description: 'The content of the item',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
displayOptions: {
show: {
operation: ['create'],
resource: ['chatMessage'],
},
},
default: {},
description: 'Other options to set',
placeholder: 'Add options',
options: [
{
displayName: 'Include Link to Workflow',
name: 'includeLinkToWorkflow',
type: 'boolean',
default: true,
description:
'Whether to append a link to this workflow at the end of the message. This is helpful if you have many workflows sending messages.',
},
],
},
/* -------------------------------------------------------------------------- */
/* chatMessage:get */

View file

@ -89,3 +89,27 @@ export async function microsoftApiRequestAllItemsSkip(
return returnData;
}
export function prepareMessage(
this: IExecuteFunctions | ILoadOptionsFunctions,
message: string,
messageType: string,
includeLinkToWorkflow: boolean,
instanceId?: string,
) {
if (includeLinkToWorkflow) {
const { id } = this.getWorkflow();
const link = `${this.getInstanceBaseUrl()}workflow/${id}?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=${encodeURIComponent(
'n8n-nodes-base.microsoftTeams',
)}${instanceId ? '_' + instanceId : ''}`;
messageType = 'html';
message = `${message}<br><br><em> Powered by <a href="${link}">this n8n workflow</a> </em>`;
}
return {
body: {
contentType: messageType,
content: message,
},
};
}

View file

@ -8,7 +8,11 @@ import type {
INodeTypeDescription,
} from 'n8n-workflow';
import { microsoftApiRequest, microsoftApiRequestAllItems } from './GenericFunctions';
import {
microsoftApiRequest,
microsoftApiRequestAllItems,
prepareMessage,
} from './GenericFunctions';
import { channelFields, channelOperations } from './ChannelDescription';
@ -24,7 +28,7 @@ export class MicrosoftTeams implements INodeType {
name: 'microsoftTeams',
icon: 'file:teams.svg',
group: ['input'],
version: 1,
version: [1, 1.1],
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Microsoft Teams API',
defaults: {
@ -266,6 +270,9 @@ export class MicrosoftTeams implements INodeType {
let responseData;
const resource = this.getNodeParameter('resource', 0);
const operation = this.getNodeParameter('operation', 0);
const nodeVersion = this.getNode().typeVersion;
const instanceId = await this.getInstanceId();
for (let i = 0; i < length; i++) {
try {
if (resource === 'channel') {
@ -365,12 +372,18 @@ export class MicrosoftTeams implements INodeType {
const message = this.getNodeParameter('message', i) as string;
const options = this.getNodeParameter('options', i);
const body: IDataObject = {
body: {
contentType: messageType,
content: message,
},
};
let includeLinkToWorkflow = options.includeLinkToWorkflow;
if (includeLinkToWorkflow === undefined) {
includeLinkToWorkflow = nodeVersion >= 1.1;
}
const body: IDataObject = prepareMessage.call(
this,
message,
messageType,
includeLinkToWorkflow as boolean,
instanceId,
);
if (options.makeReply) {
const replyToId = options.makeReply as string;
@ -420,12 +433,19 @@ export class MicrosoftTeams implements INodeType {
const chatId = this.getNodeParameter('chatId', i) as string;
const messageType = this.getNodeParameter('messageType', i) as string;
const message = this.getNodeParameter('message', i) as string;
const body: IDataObject = {
body: {
contentType: messageType,
content: message,
},
};
const options = this.getNodeParameter('options', i, {});
const includeLinkToWorkflow =
options.includeLinkToWorkflow !== false && nodeVersion >= 1.1;
const body: IDataObject = prepareMessage.call(
this,
message,
messageType,
includeLinkToWorkflow,
instanceId,
);
responseData = await microsoftApiRequest.call(
this,
'POST',

View file

@ -129,9 +129,12 @@ export async function slackApiRequestAllItems(
return returnData;
}
export function getMessageContent(this: IExecuteFunctions | ILoadOptionsFunctions, i: number) {
const nodeVersion = this.getNode().typeVersion;
export function getMessageContent(
this: IExecuteFunctions | ILoadOptionsFunctions,
i: number,
nodeVersion: number,
instanceId?: string,
) {
const includeLinkToWorkflow = this.getNodeParameter(
'otherOptions.includeLinkToWorkflow',
i,
@ -139,7 +142,9 @@ export function getMessageContent(this: IExecuteFunctions | ILoadOptionsFunction
) as IDataObject;
const { id } = this.getWorkflow();
const automatedMessage = `_Automated with this <${this.getInstanceBaseUrl()}workflow/${id}?utm_source=n8n&utm_medium=slackNode|n8n workflow>_`;
const automatedMessage = `_Automated with this <${this.getInstanceBaseUrl()}workflow/${id}?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=${encodeURIComponent(
'n8n-nodes-base.slack',
)}${instanceId ? '_' + instanceId : ''}|n8n workflow>_`;
const messageType = this.getNodeParameter('messageType', i) as string;
let content: IDataObject = {};

View file

@ -558,7 +558,7 @@ export const messageFields: INodeProperties[] = [
placeholder: 'Add options',
options: [
{
displayName: 'Include Link To Workflow',
displayName: 'Include Link to Workflow',
name: 'includeLinkToWorkflow',
type: 'boolean',
default: true,

View file

@ -322,6 +322,9 @@ export class SlackV2 implements INodeType {
const resource = this.getNodeParameter('resource', 0);
const operation = this.getNodeParameter('operation', 0);
const nodeVersion = this.getNode().typeVersion;
const instanceId = await this.getInstanceId();
for (let i = 0; i < length; i++) {
try {
responseData = {
@ -768,7 +771,7 @@ export class SlackV2 implements INodeType {
target = target.slice(0, 1) === '@' ? target : `@${target}`;
}
const { sendAsUser } = this.getNodeParameter('otherOptions', i) as IDataObject;
const content = getMessageContent.call(this, i);
const content = getMessageContent.call(this, i, nodeVersion, instanceId);
const body: IDataObject = {
channel: target,

View file

@ -64,12 +64,51 @@ export interface IMarkupReplyKeyboardRemove {
* @param {IDataObject} body The body object to add fields to
* @param {number} index The index of the item
*/
export function addAdditionalFields(this: IExecuteFunctions, body: IDataObject, index: number) {
export function addAdditionalFields(
this: IExecuteFunctions,
body: IDataObject,
index: number,
nodeVersion?: number,
instanceId?: string,
) {
const operation = this.getNodeParameter('operation', index);
// Add the additional fields
const additionalFields = this.getNodeParameter('additionalFields', index);
Object.assign(body, additionalFields);
const operation = this.getNodeParameter('operation', index);
if (operation === 'sendMessage') {
const attributionText = 'This message was sent automatically with ';
const link = `https://n8n.io/?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=${encodeURIComponent(
'n8n-nodes-base.telegram',
)}${instanceId ? '_' + instanceId : ''}`;
if (nodeVersion && nodeVersion >= 1.1 && additionalFields.appendAttribution === undefined) {
additionalFields.appendAttribution = true;
}
if (!additionalFields.parse_mode) {
additionalFields.parse_mode = 'Markdown';
}
const regex = /(https?|ftp|file):\/\/\S+|www\.\S+|\S+\.\S+/;
const containsUrl = regex.test(body.text as string);
if (!containsUrl) {
body.disable_web_page_preview = true;
}
if (additionalFields.appendAttribution) {
if (additionalFields.parse_mode === 'Markdown') {
body.text = `${body.text}\n\n_${attributionText}_[n8n](${link})`;
} else if (additionalFields.parse_mode === 'HTML') {
body.text = `${body.text}\n\n<em>${attributionText}</em><a href="${link}" target="_blank">n8n</a>`;
}
}
delete additionalFields.appendAttribution;
}
Object.assign(body, additionalFields);
// Add the reply markup
let replyMarkupOption = '';

View file

@ -17,7 +17,7 @@ export class Telegram implements INodeType {
name: 'telegram',
icon: 'file:telegram.svg',
group: ['output'],
version: 1,
version: [1, 1.1],
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Sends data to Telegram',
defaults: {
@ -1461,6 +1461,20 @@ export class Telegram implements INodeType {
},
default: {},
options: [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
displayName: 'Append n8n Attribution',
name: 'appendAttribution',
type: 'boolean',
default: true,
description:
'Whether to include the phrase “This message was sent automatically with n8n” to the end of the message',
displayOptions: {
show: {
'/operation': ['sendMessage'],
},
},
},
{
displayName: 'Caption',
name: 'caption',
@ -1693,6 +1707,9 @@ export class Telegram implements INodeType {
const resource = this.getNodeParameter('resource', 0);
const binaryData = this.getNodeParameter('binaryData', 0, false);
const nodeVersion = this.getNode().typeVersion;
const instanceId = await this.getInstanceId();
for (let i = 0; i < items.length; i++) {
try {
// Reset all values
@ -1917,7 +1934,7 @@ export class Telegram implements INodeType {
body.text = this.getNodeParameter('text', i) as string;
// Add additional fields and replyMarkup
addAdditionalFields.call(this, body, i);
addAdditionalFields.call(this, body, i, nodeVersion, instanceId);
} else if (operation === 'sendMediaGroup') {
// ----------------------------------
// message:sendMediaGroup

View file

@ -740,6 +740,7 @@ export interface FunctionsBase {
getTimezone(): string;
getRestApiUrl(): string;
getInstanceBaseUrl(): string;
getInstanceId(): Promise<string>;
getMode?: () => WorkflowExecuteMode;
getActivationMode?: () => WorkflowActivateMode;