{
  "name": "REVOPS-LI-007 | 04 | Revalidated SMTP sender and CRM logger",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 2
            }
          ]
        }
      },
      "id": "d6a314ff-0e57-455a-ada1-af1972460bbb",
      "name": "Send Queue Every Two Minutes",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {},
      "id": "d7166f76-1a99-44e5-8aba-1026aee340f8",
      "name": "Run Sender Manually",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        270,
        0
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const config={send_enabled:false,proposal_stage_ids:['REPLACE_PROPOSAL_STAGE_INTERNAL_ID'],min_age_days:5,max_age_days:7,from_email:'REPLACE_VERIFIED_FROM_EMAIL',from_name:'REPLACE_SALES_TEAM_NAME',review_after_days:3};if(config.proposal_stage_ids.some(x=>x.includes('REPLACE_')))throw new Error('Set proposal stage IDs');return [{json:{config}}];"
      },
      "id": "cf6c073f-56ab-4599-bd9c-e5d98816e2b5",
      "name": "Sender Configuration",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        540,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "5e7b8bb1-0277-415b-9025-5a8958d3090a",
              "leftValue": "={{ $json.config.send_enabled === true }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "92e40cfb-42fd-4883-9a31-a13a7bc04a5c",
      "name": "Sending Enabled?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        810,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT ghosted_proposal.claim_send($1::text) AS case;",
        "options": {
          "queryReplacement": "={{ [String($execution.id)] }}"
        }
      },
      "id": "7fd3a066-c16b-4273-b49f-b278a41d79b0",
      "name": "Claim Send-Ready Case",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        1080,
        0
      ],
      "credentials": {
        "postgres": {
          "id": "REPLACE_POSTGRES_CREDENTIAL",
          "name": "ATTRIBUTION Postgres"
        }
      }
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "0fae91a5-d311-452e-b349-da26d0dfdaa6",
              "leftValue": "={{ $json.case !== null }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "17997b32-4b6e-491c-9006-c1d2508516bd",
      "name": "Send Case Found?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1350,
        0
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const c=$('Claim Send-Ready Case').first().json.case;return [{json:{kind:'deal',id:c.deal_id,case:c}},{json:{kind:'contact',id:String(c.contact.id),case:c}}];"
      },
      "id": "b8af5176-a6e0-43de-9fc1-b654830ebe26",
      "name": "Build Fresh Deal and Contact Reads",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1620,
        0
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ 'https://api.hubapi.com/crm/v3/objects/'+($json.kind==='deal'?'deals':'contacts')+'/'+$json.id+'?properties='+encodeURIComponent(($json.kind==='deal'?[\"dealname\",\"dealstage\",\"pipeline\",\"amount\",\"hs_is_closed\",\"hubspot_owner_id\",\"hs_last_activity_date\",\"hs_last_sales_activity_timestamp\",\"notes_last_updated\",\"hs_last_contacted\",\"lastmodifieddate\",\"revops_proposal_sent_at\",\"revops_proposal_status\",\"revops_proposal_link\",\"revops_proposal_value\",\"revops_proposal_summary\",\"revops_service_proposed\",\"revops_main_pain_point\",\"revops_expected_next_step\",\"revops_salesperson_notes\",\"revops_previous_objections\",\"revops_past_communication_summary\",\"revops_proposal_channel\",\"revops_ghosting_status\",\"revops_followup_sent_at\",\"revops_next_review_at\",\"revops_last_reply_at\",\"revops_salesperson_name\"]:[\"firstname\",\"lastname\",\"email\",\"phone\",\"hubspot_owner_id\",\"hs_email_optout\",\"revops_do_not_contact\"]).join(','))+'&associations=contacts,companies' }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "timeout": 30000,
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "d9f7bb2e-0c99-413d-aee8-782df1a4dd3e",
      "name": "Read Fresh Send Context",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1890,
        0
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_HUBSPOT_CREDENTIAL",
          "name": "ATTRIBUTION HubSpot"
        }
      }
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const G = (function ghostedLibrary(){\n  const text=v=>String(v??'').trim();\n  const bool=v=>['true','1','yes','on'].includes(text(v).toLowerCase());\n  const ms=v=>{if(v==null||v==='')return null;const n=Number(v);if(Number.isFinite(n)&&n>100000000000)return n;const d=Date.parse(v);return Number.isFinite(d)?d:null};\n  const iso=v=>{const n=ms(v);return n?new Date(n).toISOString():null};\n  const latest=(...values)=>values.map(ms).filter(Boolean).sort((a,b)=>b-a)[0]||null;\n  const assoc=(record,type)=>(record.associations?.[type]?.results||[]).map(x=>String(x.id));\n  function classify(deal,config,now=Date.now()){\n    const p=deal.properties||{},proposal=ms(p.revops_proposal_sent_at),reasons=[];\n    if(!proposal)return {action:'MANUAL',status:'MISSING_PROPOSAL_DATE',reasons:['MISSING_PROPOSAL_DATE']};\n    const age=(now-proposal)/86400000;if(age<config.min_age_days||age>config.max_age_days)return {action:'SKIP',status:'OUTSIDE_FOLLOWUP_WINDOW',age_days:age,reasons:[]};\n    if(!config.proposal_stage_ids.includes(text(p.dealstage))||bool(p.hs_is_closed))return {action:'SKIP',status:'DEAL_NO_LONGER_ELIGIBLE',age_days:age,reasons:[]};\n    const activity=latest(p.hs_last_activity_date,p.hs_last_sales_activity_timestamp,p.notes_last_updated,p.hs_last_contacted,p.revops_last_reply_at);\n    const meaningful=activity;\n    const grace=Number(config.activity_grace_minutes||15)*60000;if(meaningful&&meaningful>proposal+grace)return {action:'SKIP',status:'RECENT_ACTIVITY_FOUND',age_days:age,last_activity_at:new Date(meaningful).toISOString(),reasons:[]};\n    if(['FOLLOWED_UP','RE_ENGAGED','MANUAL_REVIEW','APPROVAL_PENDING','SENDING'].includes(text(p.revops_ghosting_status)))return {action:'SKIP',status:'ALREADY_HANDLED',age_days:age,reasons:[]};\n    const contacts=assoc(deal,'contacts'),companies=assoc(deal,'companies');\n    if(contacts.length!==1)reasons.push(contacts.length?'MULTIPLE_CONTACTS':'MISSING_CONTACT');if(companies.length>1)reasons.push('MULTIPLE_COMPANIES');\n    return {action:reasons.length?'MANUAL':'CONTEXT',status:reasons.length?'ASSOCIATION_REVIEW_REQUIRED':'CONTEXT_REQUIRED',age_days:age,proposal_sent_at:new Date(proposal).toISOString(),contacts,companies,last_activity_at:activity?new Date(activity).toISOString():null,reasons};\n  }\n  function finalize(deal,contact,company,openCompanyDeals,config,pre){\n    const reasons=[...(pre.reasons||[])],p=deal.properties||{},cp=contact?.properties||{},channel=text(p.revops_proposal_channel||'email').toLowerCase();\n    if(company&&openCompanyDeals>1)reasons.push('MULTIPLE_ACTIVE_COMPANY_DEALS');\n    if(channel==='email'&&!text(cp.email))reasons.push('MISSING_CONTACT_EMAIL');\n    if(channel==='email'&&(bool(cp.hs_email_optout)||bool(cp.revops_do_not_contact)))reasons.push('CONTACT_SUPPRESSED');\n    if(!['email','crm email'].includes(channel))reasons.push('CHANNEL_REQUIRES_MANUAL_SEND');\n    const amount=Number(p.amount||p.revops_proposal_value||0);if(!Number.isFinite(amount))reasons.push('INVALID_DEAL_VALUE');\n    const highValue=Number.isFinite(amount)&&amount>=config.high_value_threshold;\n    return {deal_id:String(deal.id),deal,contact:contact||null,company:company||null,proposal_sent_at:pre.proposal_sent_at,channel,amount:Number.isFinite(amount)?amount:0,high_value:highValue,requires_approval:highValue&&!reasons.length,action:reasons.length?'MANUAL':'READY',status:reasons.length?'MANUAL_REVIEW_REQUIRED':highValue?'APPROVAL_REQUIRED':'READY_TO_DRAFT',reasons};\n  }\n  function draft(c){const d=c.deal.properties||{},p=c.contact?.properties||{};const first=text(p.firstname)||'there';const proposal=text(d.revops_service_proposed||d.dealname||'proposal');const pain=text(d.revops_main_pain_point);const next=text(d.revops_expected_next_step)||'review any questions together';const objection=text(d.revops_previous_objections);let body=`Hi ${first},\\n\\nI wanted to follow up on the ${proposal} I sent over last week.`;if(pain)body+=` Based on our conversation about ${pain}, I wanted to make sure the proposal covers the priorities we discussed.`;if(objection)body+=` I’m also happy to clarify the points around ${objection}.`;body+=`\\n\\nWould it be helpful to ${next}? If priorities have changed, just let me know and I can adjust the direction.\\n\\nBest,\\n${text(d.revops_salesperson_name)||'Your sales representative'}`;return {subject:`Quick follow-up on ${text(d.dealname)||'the proposal'}`,body};}\n  function revalidate(c,deal,contact,config){const current=classify(deal,config,Date.now()),reasons=[];if(!['CONTEXT','READY'].includes(current.action)&&current.status!=='CONTEXT_REQUIRED')reasons.push('DEAL_CHANGED_OR_ACTIVITY_FOUND');if(iso(deal.properties?.revops_proposal_sent_at)!==iso(c.proposal_sent_at))reasons.push('PROPOSAL_DATE_CHANGED');if(text(deal.properties?.revops_followup_sent_at))reasons.push('FOLLOWUP_ALREADY_SENT');if(!contact||text(contact.properties?.email).toLowerCase()!==text(c.contact?.properties?.email).toLowerCase())reasons.push('CONTACT_EMAIL_CHANGED_OR_MISSING');if(bool(contact?.properties?.hs_email_optout)||bool(contact?.properties?.revops_do_not_contact))reasons.push('CONTACT_SUPPRESSED');return {safe:!reasons.length,reasons};}\n  return {text,bool,ms,iso,latest,assoc,classify,finalize,draft,revalidate};\n})();\nconst req=$('Build Fresh Deal and Contact Reads').all(),res=$input.all(),c=req[0].json.case;let deal=null,contact=null;for(let i=0;i<req.length;i++)if(res[i].json.statusCode===200){if(req[i].json.kind==='deal')deal=res[i].json.body;else contact=res[i].json.body}const check=G.revalidate(c,deal,contact,$('Sender Configuration').first().json.config);return [{json:{case:c,deal,contact,...check}}];"
      },
      "id": "39aa1d7f-fd59-4e26-a166-445e9663381b",
      "name": "Final Send Safety Check",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        0,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "3bebba33-45a4-4922-9f0d-acd3f73f033f",
              "leftValue": "={{ $json.safe === true }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "8435c796-a055-4efc-869d-a11e1ead4c9a",
      "name": "Still Safe To Send?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        270,
        300
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT ghosted_proposal.block_send($1::uuid,$2::jsonb);",
        "options": {
          "queryReplacement": "={{ [$('Final Send Safety Check').first().json.case.case_id,JSON.stringify($('Final Send Safety Check').first().json.reasons)] }}"
        }
      },
      "id": "2467bf85-e33e-4dae-a29f-976bf4559ec3",
      "name": "Block Changed Case",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        540,
        300
      ],
      "credentials": {
        "postgres": {
          "id": "REPLACE_POSTGRES_CREDENTIAL",
          "name": "ATTRIBUTION Postgres"
        }
      }
    },
    {
      "parameters": {
        "fromEmail": "={{ $('Sender Configuration').first().json.config.from_name+' <'+$('Sender Configuration').first().json.config.from_email+'>' }}",
        "toEmail": "={{ $('Final Send Safety Check').first().json.contact.properties.email }}",
        "subject": "={{ $('Final Send Safety Check').first().json.case.draft.subject }}",
        "emailFormat": "text",
        "text": "={{ $('Final Send Safety Check').first().json.case.draft.body }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "d29e9768-887b-47d4-ab81-fedb91932e8f",
      "name": "Send Follow-Up Email",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        810,
        300
      ],
      "credentials": {
        "smtp": {
          "id": "REPLACE_SMTP_CREDENTIAL",
          "name": "Proposal Follow-Up SMTP"
        }
      }
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const c=$('Final Send Safety Check').first().json.case,cfg=$('Sender Configuration').first().json.config,now=new Date(),next=new Date(now.getTime()+cfg.review_after_days*86400000);const esc=s=>String(s||'').replace(/[&<>]/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[ch]));const note={properties:{hs_timestamp:now.toISOString(),hs_note_body:'Automated proposal follow-up sent.\\n\\nSubject: '+esc(c.draft.subject)+'\\n\\n'+esc(c.draft.body)},associations:[{to:{id:c.deal_id},types:[{associationCategory:'HUBSPOT_DEFINED',associationTypeId:214}]},{to:{id:String(c.contact.id)},types:[{associationCategory:'HUBSPOT_DEFINED',associationTypeId:202}]}]};const patch={properties:{revops_ghosting_status:'FOLLOWED_UP',revops_followup_status:'SENT',revops_followup_sent_at:now.toISOString(),revops_last_automated_followup_at:now.toISOString(),revops_followup_channel:'EMAIL',revops_followup_message:c.draft.body.slice(0,65000),revops_next_review_at:next.toISOString(),revops_followup_run_id:String($execution.id)}};return [{json:{case:c,note,patch,sent_at:now.toISOString()}}];"
      },
      "id": "b91f379a-a03d-4281-b8b5-9bbe59c5671a",
      "name": "Build CRM Log After Send",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1080,
        300
      ]
    },
    {
      "parameters": {
        "method": "PATCH",
        "url": "={{ 'https://api.hubapi.com/crm/v3/objects/deals/'+$json.case.deal_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ $json.patch }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "eb523d43-a3d8-43dd-a1e8-194341aca231",
      "name": "Update HubSpot Deal",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1350,
        300
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_HUBSPOT_CREDENTIAL",
          "name": "ATTRIBUTION HubSpot"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.hubapi.com/crm/v3/objects/notes",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ $('Build CRM Log After Send').first().json.note }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "31ead06a-13b1-4f10-ab54-1a461c1b3efc",
      "name": "Log Exact Message as Note",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1620,
        300
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_HUBSPOT_CREDENTIAL",
          "name": "ATTRIBUTION HubSpot"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT ghosted_proposal.complete_send($1::uuid,$2::timestamptz,$3::jsonb);",
        "options": {
          "queryReplacement": "={{ [$('Build CRM Log After Send').first().json.case.case_id,$('Build CRM Log After Send').first().json.sent_at,JSON.stringify($('Build CRM Log After Send').first().json.patch)] }}"
        }
      },
      "id": "a784be73-0512-4f63-8f7c-71e19700f80d",
      "name": "Complete Send Audit",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        1890,
        300
      ],
      "credentials": {
        "postgres": {
          "id": "REPLACE_POSTGRES_CREDENTIAL",
          "name": "ATTRIBUTION Postgres"
        }
      }
    },
    {
      "parameters": {
        "content": "## Outbound sender — OFF by default\nSet send_enabled=true only after sandbox/UAT. The sender claims once, fetches fresh deal/contact data, and blocks if activity, stage, date, email, or suppression changed.\nThe SMTP send node has no automatic retry. A failure after claim becomes manual reconciliation so a network ambiguity cannot create a duplicate email.",
        "width": 700,
        "height": 220
      },
      "id": "cde694c3-cc07-41b2-bcf4-c30493e309e9",
      "name": "Workflow Guide",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -270
      ]
    }
  ],
  "connections": {
    "Send Queue Every Two Minutes": {
      "main": [
        [
          {
            "node": "Sender Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run Sender Manually": {
      "main": [
        [
          {
            "node": "Sender Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sender Configuration": {
      "main": [
        [
          {
            "node": "Sending Enabled?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sending Enabled?": {
      "main": [
        [
          {
            "node": "Claim Send-Ready Case",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claim Send-Ready Case": {
      "main": [
        [
          {
            "node": "Send Case Found?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Case Found?": {
      "main": [
        [
          {
            "node": "Build Fresh Deal and Contact Reads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Fresh Deal and Contact Reads": {
      "main": [
        [
          {
            "node": "Read Fresh Send Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Fresh Send Context": {
      "main": [
        [
          {
            "node": "Final Send Safety Check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Final Send Safety Check": {
      "main": [
        [
          {
            "node": "Still Safe To Send?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Still Safe To Send?": {
      "main": [
        [
          {
            "node": "Send Follow-Up Email",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Block Changed Case",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Follow-Up Email": {
      "main": [
        [
          {
            "node": "Build CRM Log After Send",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build CRM Log After Send": {
      "main": [
        [
          {
            "node": "Update HubSpot Deal",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update HubSpot Deal": {
      "main": [
        [
          {
            "node": "Log Exact Message as Note",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log Exact Message as Note": {
      "main": [
        [
          {
            "node": "Complete Send Audit",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "Asia/Tbilisi",
    "saveExecutionProgress": true
  },
  "active": false,
  "pinData": {},
  "tags": []
}
