{
"updatedAt": "2026-08-01T16:43:21.632Z",
"createdAt": "2026-08-01T15:30:41.989Z",
"id": "S9LCeDiAgVQdL2fs",
"name": "GLM - Solicitud mensual de EEFF a contadores",
"description": null,
"active": true,
"isArchived": false,
"nodes": [
{
"parameters": {},
"id": "fc7e8055-4678-4c31-ad66-64de513bfd19",
"name": "Prueba manual segura",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
32,
384
]
},
{
"parameters": {
"rule": {
"interval": [
{
"field": "months",
"triggerAtDayOfMonth": 15,
"triggerAtHour": 8
}
]
}
},
"id": "4c04af4b-bfea-4516-8256-309fb1e67f61",
"name": "Cada día 15 a las 8 AM",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.3,
"position": [
32,
560
],
"notesInFlow": true,
"notes": "Se ejecuta el día 15 de cada mes a las 8:00 a. m. usando la zona horaria America/Santo_Domingo definida en el workflow."
},
{
"parameters": {
"url": "https://sheets.googleapis.com/v4/spreadsheets/10GN-XIna4YRdCt81EAdQCoq6adstiCF-HuCTDs_wBt8/values/%27Para%20Isaac%20%28%20IT%29%27%21B3%3AM",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleSheetsOAuth2Api",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "majorDimension",
"value": "ROWS"
},
{
"name": "valueRenderOption",
"value": "FORMATTED_VALUE"
}
]
},
"options": {
"timeout": 120000
}
},
"id": "5a4feded-814b-45fd-98aa-401793ad823e",
"name": "Leer contactos vivos del Google Sheet",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
304,
464
],
"notesInFlow": true,
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000,
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
},
"notes": "Selecciona una credencial Google Sheets OAuth2 con acceso de lectura al archivo. Lee B3:M de la hoja Para Isaac ( IT) en cada ejecución."
},
{
"parameters": {
"jsCode": "'use strict';\n\nconst CONFIG = {\n // Déjalo vacío si no deseas copiar a nadie.\n // Para varios correos, sepáralos por coma.\n CC_INTERNO: '',\n\n // La firma visible del correo mostrará únicamente este nombre.\n REMITENTE_NOMBRE: 'Ada Rodríguez',\n\n LOGO_URL:\n 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png',\n\n // El archivo se lee en vivo en cada ejecución.\n SPREADSHEET_ID: '10GN-XIna4YRdCt81EAdQCoq6adstiCF-HuCTDs_wBt8',\n HOJA: 'Para Isaac ( IT)',\n};\n\nconst response = $input.first()?.json || {};\nconst values = Array.isArray(response.values) ? response.values : [];\n\nif (values.length < 2) {\n throw new Error(\n `La hoja \"${CONFIG.HOJA}\" no contiene encabezados y filas de datos en el rango B3:M.`\n );\n}\n\nfunction clean(value) {\n return String(value ?? '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\r/g, ' ')\n .replace(/\\n+/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalize(value) {\n return clean(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, ' ')\n .trim();\n}\n\nfunction escapeHtml(value) {\n return clean(value)\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\nfunction parseEmails(value) {\n const matches = clean(value)\n .split(/[;,\\s]+/)\n .map((email) => email.trim().toLowerCase())\n .filter(Boolean)\n .filter((email) => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email));\n\n return [...new Set(matches)];\n}\n\nconst EXPECTED = [\n {\n key: 'pais',\n aliases: ['pais'],\n },\n {\n key: 'contacto',\n aliases: ['nombre del contacto'],\n },\n {\n key: 'cargo',\n aliases: ['cargo'],\n },\n {\n key: 'correo',\n aliases: ['correo electronico'],\n },\n {\n key: 'telefono',\n aliases: ['telefono'],\n },\n {\n key: 'firma_operativa',\n aliases: [\n 'nombre de la firma contable o auditoria',\n 'nombre de la firma contable auditoria',\n ],\n },\n {\n key: 'empresa_glm',\n aliases: ['nombre de glm en cada pais'],\n },\n {\n key: 'pagina_web',\n aliases: ['pagina web'],\n },\n {\n key: 'contabilidad',\n aliases: ['contabilidad'],\n },\n {\n key: 'impuestos',\n aliases: ['impuestos'],\n },\n {\n key: 'auditoria',\n aliases: ['auditoria'],\n },\n {\n key: 'firma_red',\n aliases: ['nombre de la firma contable'],\n },\n];\n\nconst headers = (values[0] || []).map(clean);\nconst normalizedHeaders = headers.map(normalize);\nconst indexByKey = {};\n\nfor (const expected of EXPECTED) {\n const accepted = expected.aliases.map(normalize);\n\n const index = normalizedHeaders.findIndex((header) =>\n accepted.includes(header)\n );\n\n if (index < 0) {\n throw new Error(\n `Falta la columna requerida \"${expected.aliases[0]}\" en la hoja \"${CONFIG.HOJA}\". ` +\n `Encabezados encontrados: ${headers.join(' | ')}`\n );\n }\n\n indexByKey[expected.key] = index;\n}\n\nconst rows = values.slice(1);\nconst validRows = [];\nconst omittedRows = [];\n\nfor (let index = 0; index < rows.length; index++) {\n // Los encabezados están en la fila 3 y los datos comienzan en la fila 4.\n const sourceRow = index + 4;\n const row = Array.isArray(rows[index]) ? rows[index] : [];\n\n const record = {};\n\n for (const expected of EXPECTED) {\n record[expected.key] = clean(row[indexByKey[expected.key]]);\n }\n\n const missing = EXPECTED\n .filter((expected) => !record[expected.key])\n .map((expected) => expected.aliases[0]);\n\n if (missing.length) {\n omittedRows.push({\n fila: sourceRow,\n motivo: `Columnas incompletas: ${missing.join(', ')}`,\n });\n\n continue;\n }\n\n const emails = parseEmails(record.correo);\n\n if (!emails.length) {\n omittedRows.push({\n fila: sourceRow,\n motivo: 'Correo electrónico inválido',\n });\n\n continue;\n }\n\n validRows.push({\n ...record,\n emails,\n sourceRow,\n });\n}\n\nif (!validRows.length) {\n throw new Error(\n `No hay filas elegibles en \"${CONFIG.HOJA}\". ` +\n `Solo se procesan filas con las 12 columnas B:M completas y un correo válido.`\n );\n}\n\n/*\n * Agrupación:\n * país + empresa GLM + firma operativa.\n *\n * Los contactos del mismo equipo reciben un solo correo.\n * Esto evita duplicados y no mezcla firmas distintas.\n */\nconst groups = new Map();\n\nfor (const row of validRows) {\n const key = [\n normalize(row.pais),\n normalize(row.empresa_glm),\n normalize(row.firma_operativa),\n ].join('||');\n\n if (!groups.has(key)) {\n groups.set(key, {\n pais: row.pais,\n empresa_glm: row.empresa_glm,\n firma_operativa: row.firma_operativa,\n firma_red: row.firma_red,\n pagina_web: row.pagina_web,\n contactos: [],\n emails: new Set(),\n sourceRows: [],\n });\n }\n\n const group = groups.get(key);\n\n group.contactos.push({\n nombre: row.contacto,\n cargo: row.cargo,\n });\n\n row.emails.forEach((email) => group.emails.add(email));\n group.sourceRows.push(row.sourceRow);\n}\n\n/*\n * Determinación automática del período:\n * siempre solicita el mes inmediatamente anterior.\n */\nconst now = new Date();\n\nconst previousMonth = new Date(\n Date.UTC(\n now.getUTCFullYear(),\n now.getUTCMonth() - 1,\n 1\n )\n);\n\nconst MONTHS = [\n 'enero',\n 'febrero',\n 'marzo',\n 'abril',\n 'mayo',\n 'junio',\n 'julio',\n 'agosto',\n 'septiembre',\n 'octubre',\n 'noviembre',\n 'diciembre',\n];\n\nconst monthName = MONTHS[previousMonth.getUTCMonth()];\n\nconst monthNameTitle =\n monthName.charAt(0).toUpperCase() +\n monthName.slice(1);\n\nconst year = previousMonth.getUTCFullYear();\n\nconst output = [];\n\nfor (const group of groups.values()) {\n const realRecipients = [...group.emails].sort();\n const recipientsLabel = realRecipients.join(', ');\n\n // Producción: siempre se envía a los destinatarios reales del Google Sheet.\n const to = recipientsLabel;\n const cc = CONFIG.CC_INTERNO;\n\n const subject =\n `GLM | Solicitud EEFF ${monthNameTitle} ${year} | ${group.pais}`;\n\n const html = `\n\n\n
\n \n\n \n\n \n\n \n\n ${escapeHtml(subject)}\n\n\n\n \n \n Solicitud de Estados Financieros correspondientes a\n ${escapeHtml(monthName)} de ${year}.\n
\n\n \n \n \n \n \n \n | \n \n | \n \n\n \n \n \n \n | \n \n\n \n \n | \n \n ${escapeHtml(monthNameTitle)} ${year}\n \n\n \n Solicitud de Estados Financieros\n \n | \n \n\n \n \n | \n Estimado equipo de\n \n ${escapeHtml(group.firma_operativa)}\n ,\n | \n \n\n \n \n | \n Reciban un cordial saludo. Agradeceremos nos compartan los\n \n Estados Financieros (EEFF)\n \n correspondientes al mes de\n \n ${escapeHtml(monthName)} de ${year}\n \n de\n \n ${escapeHtml(group.empresa_glm)}\n .\n | \n \n\n \n \n \n \n \n | \n Período solicitado\n | \n \n\n \n | \n ${escapeHtml(monthNameTitle)} ${year}\n | \n \n \n | \n \n\n \n \n | \n Si necesitan alguna aclaración o se presenta algún inconveniente\n con el envío, por favor háganmelo saber y con gusto les apoyaré.\n | \n \n\n \n \n | \n Agradezco de antemano su colaboración.\n | \n \n\n \n \n \n Saludos cordiales,\n \n \n\n \n ${escapeHtml(CONFIG.REMITENTE_NOMBRE)}\n \n | \n \n\n \n \n \n \n \n | \n Si ya han remitido los Estados Financieros correspondientes\n a este período, por favor hagan caso omiso a este correo.\n | \n \n \n | \n \n \n | \n
\n
\n\n\n `.trim();\n\n output.push({\n json: {\n to,\n cc,\n subject,\n html,\n\n pais: group.pais,\n empresa_glm: group.empresa_glm,\n firma_operativa: group.firma_operativa,\n\n // Datos disponibles únicamente dentro de n8n para auditoría.\n destinatarios: recipientsLabel,\n filas_origen: group.sourceRows.join(', '),\n entorno: 'produccion',\n\n periodo_solicitado: `${monthNameTitle} ${year}`,\n\n total_filas_validas: validRows.length,\n total_filas_omitidas: omittedRows.length,\n detalle_filas_omitidas: omittedRows,\n },\n });\n}\n\nreturn output;"
},
"id": "308dab91-4b68-4578-86ed-2e6a61071e2a",
"name": "Validar filas y construir correos GLM",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
608,
464
],
"notesInFlow": true,
"notes": "Solo procesa filas con las 12 columnas B:M completas y correo válido. Agrupa contactos por país + empresa GLM + firma. MODO_PRUEBA está activo por defecto."
},
{
"parameters": {
"sendTo": "={{ $json.to }}",
"subject": "={{ $json.subject }}",
"message": "={{ $json.html }}",
"options": {
"appendAttribution": false,
"ccList": "={{ $json.cc }}",
"senderName": "Ada Rodríguez | GomezLee Marketing"
}
},
"id": "e58a1aec-7512-4d63-969a-d9d4e9893fad",
"name": "Enviar desde Gmail de Ada",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.2,
"position": [
928,
464
],
"notesInFlow": true,
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 5000,
"webhookId": "673e3ad2-e80d-4d6a-a9c3-0d16e2891d9f",
"credentials": {
"gmailOAuth2": {
"id": "UDcO1FLJqA453V2D",
"name": "Gmail account 3"
}
},
"notes": "En la llamada con Ada, selecciona aquí la credencial Gmail OAuth2 conectada como asrodriguez@gomezleemarketing.com."
}
],
"connections": {
"Prueba manual segura": {
"main": [
[
{
"node": "Leer contactos vivos del Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Cada día 15 a las 8 AM": {
"main": [
[
{
"node": "Leer contactos vivos del Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Leer contactos vivos del Google Sheet": {
"main": [
[
{
"node": "Validar filas y construir correos GLM",
"type": "main",
"index": 0
}
]
]
},
"Validar filas y construir correos GLM": {
"main": [
[
{
"node": "Enviar desde Gmail de Ada",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"binaryMode": "separate"
},
"staticData": {
"node:Cada día 15 a las 8 AM": {
"recurrenceRules": []
}
},
"meta": {
"templateCredsSetupCompleted": true
},
"versionId": "2aca8a9b-301c-4809-80bb-bfdce75089a5",
"activeVersionId": "2aca8a9b-301c-4809-80bb-bfdce75089a5",
"versionCounter": 9,
"triggerCount": 1,
"shared": [
{
"updatedAt": "2026-08-01T15:30:41.997Z",
"createdAt": "2026-08-01T15:30:41.997Z",
"role": "workflow:owner",
"workflowId": "S9LCeDiAgVQdL2fs",
"projectId": "PJpTANzTXIFibWsW",
"project": {
"updatedAt": "2026-04-22T14:25:09.686Z",
"createdAt": "2026-04-22T14:22:54.790Z",
"id": "PJpTANzTXIFibWsW",
"name": "Isaac Aracena ",
"type": "personal",
"icon": null,
"description": null,
"creatorId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
}
}
],
"tags": [],
"activeVersion": {
"updatedAt": "2026-08-01T16:43:49.000Z",
"createdAt": "2026-08-01T16:43:21.634Z",
"versionId": "2aca8a9b-301c-4809-80bb-bfdce75089a5",
"workflowId": "S9LCeDiAgVQdL2fs",
"nodes": [
{
"parameters": {},
"id": "fc7e8055-4678-4c31-ad66-64de513bfd19",
"name": "Prueba manual segura",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
32,
384
]
},
{
"parameters": {
"rule": {
"interval": [
{
"field": "months",
"triggerAtDayOfMonth": 15,
"triggerAtHour": 8
}
]
}
},
"id": "4c04af4b-bfea-4516-8256-309fb1e67f61",
"name": "Cada día 15 a las 8 AM",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.3,
"position": [
32,
560
],
"notesInFlow": true,
"notes": "Se ejecuta el día 15 de cada mes a las 8:00 a. m. usando la zona horaria America/Santo_Domingo definida en el workflow."
},
{
"parameters": {
"url": "https://sheets.googleapis.com/v4/spreadsheets/10GN-XIna4YRdCt81EAdQCoq6adstiCF-HuCTDs_wBt8/values/%27Para%20Isaac%20%28%20IT%29%27%21B3%3AM",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleSheetsOAuth2Api",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "majorDimension",
"value": "ROWS"
},
{
"name": "valueRenderOption",
"value": "FORMATTED_VALUE"
}
]
},
"options": {
"timeout": 120000
}
},
"id": "5a4feded-814b-45fd-98aa-401793ad823e",
"name": "Leer contactos vivos del Google Sheet",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
304,
464
],
"notesInFlow": true,
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000,
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
},
"notes": "Selecciona una credencial Google Sheets OAuth2 con acceso de lectura al archivo. Lee B3:M de la hoja Para Isaac ( IT) en cada ejecución."
},
{
"parameters": {
"jsCode": "'use strict';\n\nconst CONFIG = {\n // Déjalo vacío si no deseas copiar a nadie.\n // Para varios correos, sepáralos por coma.\n CC_INTERNO: '',\n\n // La firma visible del correo mostrará únicamente este nombre.\n REMITENTE_NOMBRE: 'Ada Rodríguez',\n\n LOGO_URL:\n 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png',\n\n // El archivo se lee en vivo en cada ejecución.\n SPREADSHEET_ID: '10GN-XIna4YRdCt81EAdQCoq6adstiCF-HuCTDs_wBt8',\n HOJA: 'Para Isaac ( IT)',\n};\n\nconst response = $input.first()?.json || {};\nconst values = Array.isArray(response.values) ? response.values : [];\n\nif (values.length < 2) {\n throw new Error(\n `La hoja \"${CONFIG.HOJA}\" no contiene encabezados y filas de datos en el rango B3:M.`\n );\n}\n\nfunction clean(value) {\n return String(value ?? '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\r/g, ' ')\n .replace(/\\n+/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalize(value) {\n return clean(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, ' ')\n .trim();\n}\n\nfunction escapeHtml(value) {\n return clean(value)\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\nfunction parseEmails(value) {\n const matches = clean(value)\n .split(/[;,\\s]+/)\n .map((email) => email.trim().toLowerCase())\n .filter(Boolean)\n .filter((email) => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email));\n\n return [...new Set(matches)];\n}\n\nconst EXPECTED = [\n {\n key: 'pais',\n aliases: ['pais'],\n },\n {\n key: 'contacto',\n aliases: ['nombre del contacto'],\n },\n {\n key: 'cargo',\n aliases: ['cargo'],\n },\n {\n key: 'correo',\n aliases: ['correo electronico'],\n },\n {\n key: 'telefono',\n aliases: ['telefono'],\n },\n {\n key: 'firma_operativa',\n aliases: [\n 'nombre de la firma contable o auditoria',\n 'nombre de la firma contable auditoria',\n ],\n },\n {\n key: 'empresa_glm',\n aliases: ['nombre de glm en cada pais'],\n },\n {\n key: 'pagina_web',\n aliases: ['pagina web'],\n },\n {\n key: 'contabilidad',\n aliases: ['contabilidad'],\n },\n {\n key: 'impuestos',\n aliases: ['impuestos'],\n },\n {\n key: 'auditoria',\n aliases: ['auditoria'],\n },\n {\n key: 'firma_red',\n aliases: ['nombre de la firma contable'],\n },\n];\n\nconst headers = (values[0] || []).map(clean);\nconst normalizedHeaders = headers.map(normalize);\nconst indexByKey = {};\n\nfor (const expected of EXPECTED) {\n const accepted = expected.aliases.map(normalize);\n\n const index = normalizedHeaders.findIndex((header) =>\n accepted.includes(header)\n );\n\n if (index < 0) {\n throw new Error(\n `Falta la columna requerida \"${expected.aliases[0]}\" en la hoja \"${CONFIG.HOJA}\". ` +\n `Encabezados encontrados: ${headers.join(' | ')}`\n );\n }\n\n indexByKey[expected.key] = index;\n}\n\nconst rows = values.slice(1);\nconst validRows = [];\nconst omittedRows = [];\n\nfor (let index = 0; index < rows.length; index++) {\n // Los encabezados están en la fila 3 y los datos comienzan en la fila 4.\n const sourceRow = index + 4;\n const row = Array.isArray(rows[index]) ? rows[index] : [];\n\n const record = {};\n\n for (const expected of EXPECTED) {\n record[expected.key] = clean(row[indexByKey[expected.key]]);\n }\n\n const missing = EXPECTED\n .filter((expected) => !record[expected.key])\n .map((expected) => expected.aliases[0]);\n\n if (missing.length) {\n omittedRows.push({\n fila: sourceRow,\n motivo: `Columnas incompletas: ${missing.join(', ')}`,\n });\n\n continue;\n }\n\n const emails = parseEmails(record.correo);\n\n if (!emails.length) {\n omittedRows.push({\n fila: sourceRow,\n motivo: 'Correo electrónico inválido',\n });\n\n continue;\n }\n\n validRows.push({\n ...record,\n emails,\n sourceRow,\n });\n}\n\nif (!validRows.length) {\n throw new Error(\n `No hay filas elegibles en \"${CONFIG.HOJA}\". ` +\n `Solo se procesan filas con las 12 columnas B:M completas y un correo válido.`\n );\n}\n\n/*\n * Agrupación:\n * país + empresa GLM + firma operativa.\n *\n * Los contactos del mismo equipo reciben un solo correo.\n * Esto evita duplicados y no mezcla firmas distintas.\n */\nconst groups = new Map();\n\nfor (const row of validRows) {\n const key = [\n normalize(row.pais),\n normalize(row.empresa_glm),\n normalize(row.firma_operativa),\n ].join('||');\n\n if (!groups.has(key)) {\n groups.set(key, {\n pais: row.pais,\n empresa_glm: row.empresa_glm,\n firma_operativa: row.firma_operativa,\n firma_red: row.firma_red,\n pagina_web: row.pagina_web,\n contactos: [],\n emails: new Set(),\n sourceRows: [],\n });\n }\n\n const group = groups.get(key);\n\n group.contactos.push({\n nombre: row.contacto,\n cargo: row.cargo,\n });\n\n row.emails.forEach((email) => group.emails.add(email));\n group.sourceRows.push(row.sourceRow);\n}\n\n/*\n * Determinación automática del período:\n * siempre solicita el mes inmediatamente anterior.\n */\nconst now = new Date();\n\nconst previousMonth = new Date(\n Date.UTC(\n now.getUTCFullYear(),\n now.getUTCMonth() - 1,\n 1\n )\n);\n\nconst MONTHS = [\n 'enero',\n 'febrero',\n 'marzo',\n 'abril',\n 'mayo',\n 'junio',\n 'julio',\n 'agosto',\n 'septiembre',\n 'octubre',\n 'noviembre',\n 'diciembre',\n];\n\nconst monthName = MONTHS[previousMonth.getUTCMonth()];\n\nconst monthNameTitle =\n monthName.charAt(0).toUpperCase() +\n monthName.slice(1);\n\nconst year = previousMonth.getUTCFullYear();\n\nconst output = [];\n\nfor (const group of groups.values()) {\n const realRecipients = [...group.emails].sort();\n const recipientsLabel = realRecipients.join(', ');\n\n // Producción: siempre se envía a los destinatarios reales del Google Sheet.\n const to = recipientsLabel;\n const cc = CONFIG.CC_INTERNO;\n\n const subject =\n `GLM | Solicitud EEFF ${monthNameTitle} ${year} | ${group.pais}`;\n\n const html = `\n\n\n\n \n\n \n\n \n\n \n\n ${escapeHtml(subject)}\n\n\n\n \n \n Solicitud de Estados Financieros correspondientes a\n ${escapeHtml(monthName)} de ${year}.\n
\n\n \n \n \n \n \n \n | \n \n | \n \n\n \n \n \n \n | \n \n\n \n \n | \n \n ${escapeHtml(monthNameTitle)} ${year}\n \n\n \n Solicitud de Estados Financieros\n \n | \n \n\n \n \n | \n Estimado equipo de\n \n ${escapeHtml(group.firma_operativa)}\n ,\n | \n \n\n \n \n | \n Reciban un cordial saludo. Agradeceremos nos compartan los\n \n Estados Financieros (EEFF)\n \n correspondientes al mes de\n \n ${escapeHtml(monthName)} de ${year}\n \n de\n \n ${escapeHtml(group.empresa_glm)}\n .\n | \n \n\n \n \n \n \n \n | \n Período solicitado\n | \n \n\n \n | \n ${escapeHtml(monthNameTitle)} ${year}\n | \n \n \n | \n \n\n \n \n | \n Si necesitan alguna aclaración o se presenta algún inconveniente\n con el envío, por favor háganmelo saber y con gusto les apoyaré.\n | \n \n\n \n \n | \n Agradezco de antemano su colaboración.\n | \n \n\n \n \n \n Saludos cordiales,\n \n \n\n \n ${escapeHtml(CONFIG.REMITENTE_NOMBRE)}\n \n | \n \n\n \n \n \n \n \n | \n Si ya han remitido los Estados Financieros correspondientes\n a este período, por favor hagan caso omiso a este correo.\n | \n \n \n | \n \n \n | \n
\n
\n\n\n `.trim();\n\n output.push({\n json: {\n to,\n cc,\n subject,\n html,\n\n pais: group.pais,\n empresa_glm: group.empresa_glm,\n firma_operativa: group.firma_operativa,\n\n // Datos disponibles únicamente dentro de n8n para auditoría.\n destinatarios: recipientsLabel,\n filas_origen: group.sourceRows.join(', '),\n entorno: 'produccion',\n\n periodo_solicitado: `${monthNameTitle} ${year}`,\n\n total_filas_validas: validRows.length,\n total_filas_omitidas: omittedRows.length,\n detalle_filas_omitidas: omittedRows,\n },\n });\n}\n\nreturn output;"
},
"id": "308dab91-4b68-4578-86ed-2e6a61071e2a",
"name": "Validar filas y construir correos GLM",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
608,
464
],
"notesInFlow": true,
"notes": "Solo procesa filas con las 12 columnas B:M completas y correo válido. Agrupa contactos por país + empresa GLM + firma. MODO_PRUEBA está activo por defecto."
},
{
"parameters": {
"sendTo": "={{ $json.to }}",
"subject": "={{ $json.subject }}",
"message": "={{ $json.html }}",
"options": {
"appendAttribution": false,
"ccList": "={{ $json.cc }}",
"senderName": "Ada Rodríguez | GomezLee Marketing"
}
},
"id": "e58a1aec-7512-4d63-969a-d9d4e9893fad",
"name": "Enviar desde Gmail de Ada",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.2,
"position": [
928,
464
],
"notesInFlow": true,
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 5000,
"webhookId": "673e3ad2-e80d-4d6a-a9c3-0d16e2891d9f",
"credentials": {
"gmailOAuth2": {
"id": "UDcO1FLJqA453V2D",
"name": "Gmail account 3"
}
},
"notes": "En la llamada con Ada, selecciona aquí la credencial Gmail OAuth2 conectada como asrodriguez@gomezleemarketing.com."
}
],
"connections": {
"Prueba manual segura": {
"main": [
[
{
"node": "Leer contactos vivos del Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Cada día 15 a las 8 AM": {
"main": [
[
{
"node": "Leer contactos vivos del Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Leer contactos vivos del Google Sheet": {
"main": [
[
{
"node": "Validar filas y construir correos GLM",
"type": "main",
"index": 0
}
]
]
},
"Validar filas y construir correos GLM": {
"main": [
[
{
"node": "Enviar desde Gmail de Ada",
"type": "main",
"index": 0
}
]
]
}
},
"authors": "Isaac Aracena",
"name": "Version 2aca8a9b",
"description": "",
"autosaved": true,
"workflowPublishHistory": [
{
"createdAt": "2026-08-01T16:43:49.920Z",
"id": 3844,
"workflowId": "S9LCeDiAgVQdL2fs",
"versionId": "2aca8a9b-301c-4809-80bb-bfdce75089a5",
"event": "activated",
"userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
}
]
}
}