This workflow corresponds to n8n.io template #13034 — we link there as the canonical source.
This workflow follows the Gmail → Gmail Trigger recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"meta": {
"templateCredsSetupCompleted": true
},
"nodes": [
{
"id": "4b7a5274-552f-4b62-bc0b-ec1e58cb9b32",
"name": "Sticky Note - Intake",
"type": "n8n-nodes-base.stickyNote",
"position": [
-544,
1376
],
"parameters": {
"color": 7,
"width": 420,
"height": 492,
"content": "## \ud83d\udce7 PHASE 1: Intelligent Intake & Validation\nMonitors Gmail for resume submissions. Validates PDF attachments and extracts raw text content for AI processing. Handles multiple attachment formats and sizes."
},
"typeVersion": 1
},
{
"id": "5018eb75-07bc-48ec-b7eb-8e3936e85cca",
"name": "Sticky Note - Analysis",
"type": "n8n-nodes-base.stickyNote",
"position": [
-96,
1376
],
"parameters": {
"color": 7,
"width": 480,
"height": 540,
"content": "## \ud83e\udde0 PHASE 2: AI-Powered Resume Analysis\nExtracts structured data: contact info, skills, experience, education. Uses NLP pattern matching and scoring algorithms to calculate qualification metrics. Assigns candidate tier (A/B/C/D)."
},
"typeVersion": 1
},
{
"id": "bf6b3e63-6361-4ce7-a672-b0d9923f3937",
"name": "Sticky Note - Routing",
"type": "n8n-nodes-base.stickyNote",
"position": [
416,
1376
],
"parameters": {
"color": 7,
"width": 500,
"height": 588,
"content": "## \ud83c\udfaf PHASE 3: Smart Routing & Integration\nQualified candidates (70+ score) \u2192 HubSpot CRM + Slack alert + Drive archive. Unqualified candidates \u2192 Automated rejection email with personalized feedback. All actions logged to analytics."
},
"typeVersion": 1
},
{
"id": "7dd69637-b8c1-4419-98d5-91e220ba2efd",
"name": "Sticky Note - Analytics",
"type": "n8n-nodes-base.stickyNote",
"position": [
976,
1376
],
"parameters": {
"color": 7,
"width": 740,
"height": 572,
"content": "## \ud83d\udcca PHASE 4: Analytics & Feedback Loop\nTracks hiring funnel metrics, candidate quality trends, and time-to-hire. Feeds data back to scoring model for continuous improvement."
},
"typeVersion": 1
},
{
"id": "d53b1abf-f411-4d4d-a20f-ca94b27f723a",
"name": "IF: Valid PDF Attachment?",
"type": "n8n-nodes-base.if",
"position": [
-256,
1568
],
"parameters": {
"options": {},
"conditions": {
"options": {
"caseSensitive": false
},
"conditions": [
{
"id": "attachment-check",
"operator": {
"type": "array",
"operation": "notEmpty"
},
"leftValue": "={{ $json.attachments }}",
"rightValue": ""
},
{
"id": "pdf-check",
"operator": {
"type": "string",
"operation": "equals"
},
"leftValue": "={{ $json.attachments[0].mimeType }}",
"rightValue": "application/pdf"
}
]
}
},
"typeVersion": 2
},
{
"id": "4e4e9256-0a2b-48e3-8afe-1c6de2787533",
"name": "Code: Pre-Validation",
"type": "n8n-nodes-base.code",
"position": [
-384,
1712
],
"parameters": {
"jsCode": "// Pre-Processing: Attachment Validation & Metadata Extraction\nconst items = $input.all();\nconst results = [];\n\nfor (const item of items) {\n const attachments = item.json.attachments || [];\n \n if (attachments.length === 0) {\n results.push({\n json: {\n ...item.json,\n validationError: 'No attachments found',\n skipProcessing: true\n }\n });\n continue;\n }\n \n // Find PDF attachment\n const pdfAttachment = attachments.find(att => \n att.mimeType === 'application/pdf' || \n att.fileName?.toLowerCase().endsWith('.pdf')\n );\n \n if (!pdfAttachment) {\n results.push({\n json: {\n ...item.json,\n validationError: 'No PDF resume found',\n skipProcessing: true\n }\n });\n continue;\n }\n \n // Extract metadata\n const fileSizeKB = (pdfAttachment.size || 0) / 1024;\n const applicantEmail = item.json.from?.address || 'user@example.com';\n const applicantName = item.json.from?.name || 'Unknown Applicant';\n const receivedDate = item.json.date || new Date().toISOString();\n const emailSubject = item.json.subject || 'No Subject';\n \n // Validate file size (reject if > 10MB)\n if (fileSizeKB > 10240) {\n results.push({\n json: {\n ...item.json,\n validationError: 'File size exceeds 10MB limit',\n skipProcessing: true,\n fileSizeKB: fileSizeKB.toFixed(2)\n }\n });\n continue;\n }\n \n results.push({\n json: {\n ...item.json,\n pdfAttachment: pdfAttachment,\n applicantEmail: applicantEmail,\n applicantName: applicantName,\n receivedDate: receivedDate,\n emailSubject: emailSubject,\n fileSizeKB: fileSizeKB.toFixed(2),\n skipProcessing: false,\n processingTimestamp: new Date().toISOString()\n },\n binary: item.binary\n });\n}\n\nreturn results;"
},
"typeVersion": 2
},
{
"id": "f9c391fd-a9b9-4213-940e-94b439bb6106",
"name": "PDF to Text: Extract Content",
"type": "n8n-nodes-htmlcsstopdf.htmlcsstopdf",
"position": [
-32,
1568
],
"parameters": {
"resource": "pdfManipulation",
"operation": "parsePdfToJson"
},
"credentials": {
"htmlcsstopdfApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "8b37d04a-0ebf-46fd-8819-8ee9bb01401f",
"name": "Code: AI Resume Parser",
"type": "n8n-nodes-base.code",
"position": [
176,
1568
],
"parameters": {
"jsCode": "// Advanced Resume Parser with Enhanced Extraction\nconst item = $input.first();\nconst text = item.json.text || '';\nconst fullText = text.toLowerCase();\n\n// ==================== CONTACT INFORMATION ====================\n// Extract Email (prioritize from PDF content)\nconst emailMatches = text.match(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})/gi) || [];\nconst email = emailMatches[0] || item.json.applicantEmail || 'user@example.com';\n\n// Extract Phone (multiple formats)\nconst phonePatterns = [\n /\\+?1?[-.]?\\(?([0-9]{3})\\)?[-.]?([0-9]{3})[-.]?([0-9]{4})/,\n /\\(?([0-9]{3})\\)?[-.\\s]?([0-9]{3})[-.\\s]?([0-9]{4})/,\n /([0-9]{3})[-.\\s]?([0-9]{3})[-.\\s]?([0-9]{4})/\n];\nlet phone = 'Not Found';\nfor (const pattern of phonePatterns) {\n const match = text.match(pattern);\n if (match) {\n phone = match[0];\n break;\n }\n}\n\n// Extract LinkedIn\nconst linkedInMatch = text.match(/linkedin\\.com\\/in\\/([a-zA-Z0-9-]+)/i);\nconst linkedIn = linkedInMatch ? linkedInMatch[0] : 'Not Found';\n\n// Extract Location (City, State)\nconst locationMatch = text.match(/([A-Z][a-z]+(?:\\s[A-Z][a-z]+)*),\\s*([A-Z]{2})/i);\nconst location = locationMatch ? `${locationMatch[1]}, ${locationMatch[2]}` : 'Not Found';\n\n// ==================== SKILLS EXTRACTION ====================\nconst skillCategories = {\n programming: ['Python', 'JavaScript', 'Java', 'C\\\\+\\\\+', 'C#', 'Ruby', 'PHP', 'Swift', 'Kotlin', 'Go', 'Rust', 'TypeScript'],\n frontend: ['React', 'Angular', 'Vue', 'HTML', 'CSS', 'jQuery', 'Bootstrap', 'Tailwind'],\n backend: ['Node.js', 'Express', 'Django', 'Flask', 'Spring', 'Laravel', '.NET', 'FastAPI'],\n database: ['SQL', 'MySQL', 'PostgreSQL', 'MongoDB', 'Redis', 'Oracle', 'DynamoDB', 'Cassandra'],\n cloud: ['AWS', 'Azure', 'GCP', 'Heroku', 'DigitalOcean', 'Kubernetes', 'Docker'],\n tools: ['Git', 'Jenkins', 'JIRA', 'Postman', 'Selenium', 'Terraform', 'Ansible'],\n data: ['Machine Learning', 'Data Science', 'Pandas', 'NumPy', 'TensorFlow', 'PyTorch', 'Tableau', 'Power BI']\n};\n\nconst foundSkills = {\n programming: [],\n frontend: [],\n backend: [],\n database: [],\n cloud: [],\n tools: [],\n data: [],\n all: []\n};\n\nfor (const [category, skills] of Object.entries(skillCategories)) {\n for (const skill of skills) {\n const regex = new RegExp(`\\\\b${skill}\\\\b`, 'i');\n if (regex.test(text)) {\n foundSkills[category].push(skill);\n foundSkills.all.push(skill);\n }\n }\n}\n\n// ==================== EXPERIENCE EXTRACTION ====================\n// Extract year ranges (e.g., 2018-2022, 2020-Present)\nconst yearRanges = text.match(/\\b(20\\d{2}|19\\d{2})\\s*[-\u2013\u2014]\\s*(?:(20\\d{2}|19\\d{2})|present|current)/gi) || [];\nlet totalYears = 0;\n\nfor (const range of yearRanges) {\n const match = range.match(/\\b(20\\d{2}|19\\d{2})\\s*[-\u2013\u2014]\\s*(?:(20\\d{2}|19\\d{2})|present|current)/i);\n if (match) {\n const startYear = parseInt(match[1]);\n const endYear = match[2] ? parseInt(match[2]) : new Date().getFullYear();\n totalYears += (endYear - startYear);\n }\n}\n\n// Fallback: Look for explicit years of experience\nconst expMatch = text.match(/(\\d+)\\+?\\s*years?\\s*(?:of)?\\s*experience/i);\nif (expMatch && totalYears === 0) {\n totalYears = parseInt(expMatch[1]);\n}\n\n// ==================== EDUCATION EXTRACTION ====================\nconst degreeKeywords = {\n phd: /ph\\.?d|doctorate|doctoral/i,\n masters: /master'?s?|m\\.?s\\.?|m\\.?a\\.?|mba/i,\n bachelors: /bachelor'?s?|b\\.?s\\.?|b\\.?a\\.?|b\\.?tech|b\\.?e\\.?/i,\n associate: /associate'?s?|a\\.?s\\.?|a\\.?a\\.?/i\n};\n\nlet highestDegree = 'None';\nlet hasDegree = false;\n\nif (degreeKeywords.phd.test(text)) {\n highestDegree = 'PhD';\n hasDegree = true;\n} else if (degreeKeywords.masters.test(text)) {\n highestDegree = 'Masters';\n hasDegree = true;\n} else if (degreeKeywords.bachelors.test(text)) {\n highestDegree = 'Bachelors';\n hasDegree = true;\n} else if (degreeKeywords.associate.test(text)) {\n highestDegree = 'Associate';\n hasDegree = true;\n}\n\n// Extract University/College names\nconst universityMatch = text.match(/(?:university|college|institute)\\s+of\\s+([A-Z][a-z]+(?:\\s[A-Z][a-z]+)*)/i);\nconst university = universityMatch ? universityMatch[0] : 'Not Found';\n\n// ==================== CERTIFICATIONS ====================\nconst certKeywords = ['AWS Certified', 'Google Certified', 'Microsoft Certified', 'PMP', 'CISSP', 'Scrum Master', 'Six Sigma'];\nconst certifications = [];\nfor (const cert of certKeywords) {\n if (new RegExp(`\\\\b${cert}\\\\b`, 'i').test(text)) {\n certifications.push(cert);\n }\n}\n\n// ==================== SCORING ALGORITHM ====================\nlet score = 0;\n\n// Skills Score (max 40 points)\nscore += Math.min(foundSkills.all.length * 4, 40);\n\n// Experience Score (max 30 points)\nif (totalYears >= 10) score += 30;\nelse if (totalYears >= 7) score += 25;\nelse if (totalYears >= 5) score += 20;\nelse if (totalYears >= 3) score += 15;\nelse if (totalYears >= 1) score += 10;\nelse score += 5;\n\n// Education Score (max 20 points)\nif (highestDegree === 'PhD') score += 20;\nelse if (highestDegree === 'Masters') score += 15;\nelse if (highestDegree === 'Bachelors') score += 10;\nelse if (highestDegree === 'Associate') score += 5;\n\n// Certification Bonus (max 10 points)\nscore += Math.min(certifications.length * 5, 10);\n\n// Ensure score doesn't exceed 100\nscore = Math.min(score, 100);\n\n// ==================== QUALIFICATION TIERS ====================\nconst qualified = score >= 70;\nconst tier = score >= 90 ? 'A+' : \n score >= 85 ? 'A' : \n score >= 70 ? 'B' : \n score >= 50 ? 'C' : 'D';\n\nconst tierDescription = {\n 'A+': 'Exceptional Candidate - Immediate Interview',\n 'A': 'Strong Candidate - Priority Review',\n 'B': 'Qualified Candidate - Standard Review',\n 'C': 'Marginal Candidate - Consider for Junior Roles',\n 'D': 'Unqualified - Send Rejection'\n};\n\n// ==================== KEYWORD MATCHING ====================\nconst jobKeywords = ['agile', 'scrum', 'ci/cd', 'rest api', 'microservices', 'devops', 'leadership', 'team lead'];\nconst matchedKeywords = [];\nfor (const keyword of jobKeywords) {\n if (new RegExp(`\\\\b${keyword}\\\\b`, 'i').test(text)) {\n matchedKeywords.push(keyword);\n }\n}\n\n// ==================== RETURN ENRICHED DATA ====================\nitem.json.candidateEmail = email;\nitem.json.candidatePhone = phone;\nitem.json.candidateLinkedIn = linkedIn;\nitem.json.candidateLocation = location;\nitem.json.skills = foundSkills.all;\nitem.json.skillsByCategory = {\n programming: foundSkills.programming,\n frontend: foundSkills.frontend,\n backend: foundSkills.backend,\n database: foundSkills.database,\n cloud: foundSkills.cloud,\n tools: foundSkills.tools,\n data: foundSkills.data\n};\nitem.json.totalSkills = foundSkills.all.length;\nitem.json.yearsExperience = totalYears;\nitem.json.highestDegree = highestDegree;\nitem.json.hasDegree = hasDegree;\nitem.json.university = university;\nitem.json.certifications = certifications;\nitem.json.matchedKeywords = matchedKeywords;\nitem.json.qualificationScore = score;\nitem.json.qualified = qualified;\nitem.json.tier = tier;\nitem.json.tierDescription = tierDescription[tier];\nitem.json.candidateName = item.json.applicantName || 'Unknown';\nitem.json.analysisTimestamp = new Date().toISOString();\n\nreturn item;"
},
"typeVersion": 2
},
{
"id": "610619d2-f3d6-4463-9cdc-bbd7e189f3c5",
"name": "PostgreSQL: Log Application",
"type": "n8n-nodes-base.postgres",
"position": [
192,
1760
],
"parameters": {
"query": "=INSERT INTO candidate_applications (\n email,\n name,\n phone,\n linkedin,\n location,\n skills,\n years_experience,\n highest_degree,\n university,\n certifications,\n qualification_score,\n tier,\n qualified,\n received_date,\n processed_date\n) VALUES (\n '{{ $json.candidateEmail }}',\n '{{ $json.candidateName }}',\n '{{ $json.candidatePhone }}',\n '{{ $json.candidateLinkedIn }}',\n '{{ $json.candidateLocation }}',\n '{{ JSON.stringify($json.skills) }}',\n {{ $json.yearsExperience }},\n '{{ $json.highestDegree }}',\n '{{ $json.university }}',\n '{{ JSON.stringify($json.certifications) }}',\n {{ $json.qualificationScore }},\n '{{ $json.tier }}',\n {{ $json.qualified }},\n '{{ $json.receivedDate }}',\n NOW()\n) RETURNING id;",
"options": {},
"operation": "executeQuery"
},
"typeVersion": 2.4
},
{
"id": "85f186c5-06fa-4f3a-af49-53a1b61f9db1",
"name": "IF: Qualified Candidate?",
"type": "n8n-nodes-base.if",
"position": [
416,
1568
],
"parameters": {
"options": {},
"conditions": {
"options": {},
"conditions": [
{
"id": "qualified-check",
"operator": {
"type": "boolean",
"operation": "equals"
},
"leftValue": "={{ $json.qualified }}",
"rightValue": true
}
]
}
},
"typeVersion": 2
},
{
"id": "dd2ca3bb-9855-48ec-9ad2-e4eb766f05e0",
"name": "HubSpot: Create Contact",
"type": "n8n-nodes-base.hubspot",
"position": [
624,
1456
],
"parameters": {
"operation": "create",
"authentication": "appToken"
},
"credentials": {
"hubspotAppToken": {
"name": "<your credential>"
}
},
"typeVersion": 2
},
{
"id": "4befb2f3-3469-4f63-905c-447720df8d01",
"name": "Slack: Qualified Alert",
"type": "n8n-nodes-base.slack",
"position": [
848,
1456
],
"parameters": {
"text": "=\ud83c\udfaf *New Qualified Candidate!*\n\n*Name:* {{ $json.candidateName }}\n*Email:* {{ $json.candidateEmail }}\n*Phone:* {{ $json.candidatePhone }}\n*Location:* {{ $json.candidateLocation }}\n\n*\ud83d\udcca Qualification Details:*\n\u2022 *Score:* {{ $json.qualificationScore }}/100\n\u2022 *Tier:* {{ $json.tier }} - {{ $json.tierDescription }}\n\u2022 *Experience:* {{ $json.yearsExperience }} years\n\u2022 *Education:* {{ $json.highestDegree }} from {{ $json.university }}\n\n*\ud83d\udcbc Skills ({{ $json.totalSkills }}):*\n{{ $json.skills.slice(0, 10).join(', ') }}{{ $json.totalSkills > 10 ? '...' : '' }}\n\n*\ud83c\udf93 Certifications:*\n{{ $json.certifications.length > 0 ? $json.certifications.join(', ') : 'None listed' }}\n\n*\ud83d\udd17 LinkedIn:* {{ $json.candidateLinkedIn }}\n\n*\u2705 Action:* Contact added to HubSpot. Resume archived to Google Drive.\n*\ud83d\udcc5 Received:* {{ $now.toFormat('MMM dd, yyyy HH:mm') }}",
"select": "channel",
"channelId": "={{ $vars.SLACK_HR_CHANNEL_ID }}",
"otherOptions": {
"includeLinkToWorkflow": false
},
"authentication": "oAuth2"
},
"typeVersion": 2.1
},
{
"id": "0ca06d09-1ce1-4b1a-b641-d106444f4f8e",
"name": "Google Drive: Archive Qualified",
"type": "n8n-nodes-base.googleDrive",
"position": [
624,
1568
],
"parameters": {
"name": "={{ $json.candidateName.replace(/\\s+/g, '_') }}_Resume_{{ $now.toFormat('yyyy-MM-dd') }}.pdf",
"driveId": {
"__rl": true,
"mode": "list",
"value": "My Drive"
},
"options": {},
"folderId": {
"__rl": true,
"mode": "list",
"value": "qualified_resumes_folder_id"
}
},
"credentials": {
"googleDriveOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 3
},
{
"id": "f2aed3d3-efe7-442d-b0c9-d688b4c43c0f",
"name": "Google Drive: Archive Rejected",
"type": "n8n-nodes-base.googleDrive",
"position": [
624,
1808
],
"parameters": {
"name": "={{ $json.candidateName.replace(/\\s+/g, '_') }}_Resume_{{ $now.toFormat('yyyy-MM-dd') }}.pdf",
"driveId": {
"__rl": true,
"mode": "list",
"value": "My Drive"
},
"options": {},
"folderId": {
"__rl": true,
"mode": "list",
"value": "rejected_resumes_folder_id"
}
},
"credentials": {
"googleDriveOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 3
},
{
"id": "4e4ff844-1f01-4082-9c88-4a2d5ff85806",
"name": "Gmail: Send Rejection",
"type": "n8n-nodes-base.gmail",
"position": [
848,
1664
],
"parameters": {
"sendTo": "={{ $json.candidateEmail }}",
"message": "=<!DOCTYPE html>\n<html>\n<head>\n <style>\n body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }\n .container { max-width: 600px; margin: 0 auto; padding: 20px; }\n .header { background: #f4f4f4; padding: 20px; text-align: center; border-bottom: 3px solid #e74c3c; }\n .content { padding: 20px; }\n .footer { background: #f4f4f4; padding: 15px; text-align: center; font-size: 12px; color: #888; }\n .score-box { background: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; }\n </style>\n</head>\n<body>\n <div class=\"container\">\n <div class=\"header\">\n <h2>Thank You for Your Application</h2>\n </div>\n <div class=\"content\">\n <p>Dear {{ $json.candidateName.split(' ')[0] }},</p>\n \n <p>Thank you for your interest in joining our team. We have carefully reviewed your application and resume.</p>\n \n <div class=\"score-box\">\n <strong>Application Assessment:</strong><br/>\n Qualification Score: {{ $json.qualificationScore }}/100 (Tier {{ $json.tier }})<br/>\n Experience: {{ $json.yearsExperience }} years<br/>\n Education: {{ $json.highestDegree }}\n </div>\n \n <p>After careful consideration, we have decided to move forward with other candidates whose qualifications more closely match our current requirements at this time.</p>\n \n <p><strong>Feedback for your professional development:</strong></p>\n <ul>\n <li>Your application showed {{ $json.totalSkills }} relevant skills</li>\n <li>Consider gaining more experience in: {{ $json.skillsByCategory.cloud.length === 0 ? 'Cloud technologies (AWS, Azure)' : 'Advanced frameworks' }}</li>\n <li>{{ $json.certifications.length === 0 ? 'Professional certifications could strengthen your profile' : 'Your certifications are a strong asset' }}</li>\n </ul>\n \n <p>We encourage you to apply for future openings that may better align with your qualifications. We will keep your resume on file for 6 months.</p>\n \n <p>We wish you the best of luck in your job search and future career endeavors.</p>\n \n <p>Best regards,<br/>\n <strong>HR Team</strong></p>\n </div>\n <div class=\"footer\">\n <p>This is an automated message. Please do not reply directly to this email.</p>\n <p>Follow us on LinkedIn for future opportunities!</p>\n </div>\n </div>\n</body>\n</html>",
"options": {
"ccList": "",
"bccList": ""
},
"subject": "=Thank you for your application - {{ $json.candidateName }}"
},
"credentials": {
"gmailOAuth2": {
"name": "<your credential>"
}
},
"typeVersion": 2.1
},
{
"id": "9d50522c-317c-4212-9878-be42c3b51332",
"name": "Slack: Rejection Log",
"type": "n8n-nodes-base.slack",
"position": [
848,
1808
],
"parameters": {
"text": "=\u274c *Application Rejected*\n\n*Name:* {{ $json.candidateName }}\n*Email:* {{ $json.candidateEmail }}\n*Score:* {{ $json.qualificationScore }}/100 (Tier {{ $json.tier }})\n*Experience:* {{ $json.yearsExperience }} years\n*Skills:* {{ $json.totalSkills }} identified\n\n*Reason:* Score below 70 threshold\n*Action:* Rejection email sent. Resume archived.",
"select": "channel",
"channelId": "={{ $vars.SLACK_HR_CHANNEL_ID }}",
"otherOptions": {},
"authentication": "oAuth2"
},
"typeVersion": 2.1
},
{
"id": "5516c15a-43fb-4387-8271-f5fd64bd1cca",
"name": "Merge: Qualified Path",
"type": "n8n-nodes-base.merge",
"position": [
1072,
1552
],
"parameters": {},
"typeVersion": 2.1
},
{
"id": "3fbfa060-63c3-43ef-8668-59a0204b7b2d",
"name": "Merge: Rejected Path",
"type": "n8n-nodes-base.merge",
"position": [
1088,
1776
],
"parameters": {},
"typeVersion": 2.1
},
{
"id": "fe7f00a4-4470-4b17-ab65-a681980f36de",
"name": "Code: Analytics Calculator",
"type": "n8n-nodes-base.code",
"position": [
1296,
1632
],
"parameters": {
"jsCode": "// Analytics Aggregator: Calculate Hiring Funnel Metrics\nconst items = $input.all();\nconst results = [];\n\nfor (const item of items) {\n const data = item.json;\n \n // Calculate processing time\n const received = new Date(data.receivedDate);\n const processed = new Date(data.analysisTimestamp);\n const processingTimeSeconds = (processed - received) / 1000;\n \n // Determine outcome\n const outcome = data.qualified ? 'Accepted' : 'Rejected';\n \n // Calculate skill match percentage\n const totalPossibleSkills = 45; // Sum of all skill categories\n const skillMatchPercentage = ((data.totalSkills / totalPossibleSkills) * 100).toFixed(1);\n \n results.push({\n json: {\n ...data,\n analytics: {\n outcome: outcome,\n processingTimeSeconds: processingTimeSeconds.toFixed(2),\n skillMatchPercentage: skillMatchPercentage,\n hasLinkedIn: data.candidateLinkedIn !== 'Not Found',\n hasCertifications: data.certifications.length > 0,\n degreeLevel: data.highestDegree,\n experienceLevel: data.yearsExperience >= 7 ? 'Senior' : \n data.yearsExperience >= 3 ? 'Mid-Level' : 'Junior',\n timestamp: new Date().toISOString()\n }\n }\n });\n}\n\nreturn results;"
},
"typeVersion": 2
},
{
"id": "57e30780-87ae-487b-a7af-eb04966b429f",
"name": "PostgreSQL: Store Analytics",
"type": "n8n-nodes-base.postgres",
"position": [
1520,
1632
],
"parameters": {
"query": "=INSERT INTO hiring_funnel_analytics (\n outcome,\n qualification_score,\n tier,\n years_experience,\n experience_level,\n total_skills,\n skill_match_percentage,\n highest_degree,\n has_certifications,\n has_linkedin,\n processing_time_seconds,\n received_date,\n processed_date\n) VALUES (\n '{{ $json.analytics.outcome }}',\n {{ $json.qualificationScore }},\n '{{ $json.tier }}',\n {{ $json.yearsExperience }},\n '{{ $json.analytics.experienceLevel }}',\n {{ $json.totalSkills }},\n {{ $json.analytics.skillMatchPercentage }},\n '{{ $json.highestDegree }}',\n {{ $json.analytics.hasCertifications }},\n {{ $json.analytics.hasLinkedIn }},\n {{ $json.analytics.processingTimeSeconds }},\n '{{ $json.receivedDate }}',\n '{{ $json.analysisTimestamp }}'\n);",
"options": {},
"operation": "executeQuery"
},
"typeVersion": 2.4
},
{
"id": "7594e701-4305-4be0-ad52-0cbd223932fe",
"name": "Documentation",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1296,
672
],
"parameters": {
"width": 692,
"height": 656,
"content": "## \u2696\ufe0f Talent Sovereign: AI Resume Intelligence Hub\n\nIndustrial-grade recruitment pipeline: Gmail Intake \u2192 AI Parsing \u2192 Scoring \u2192 Smart Routing.\n\n### \u2699\ufe0f Core Sovereign Logic\n* **PHASE 1: Intake & Validation:** Monitors Gmail for PDF resumes; validates file integrity and metadata.\n* **PHASE 2: AI Parsing:** Uses **Parse PDF to JSON** to extract skills, experience, and contact data.\n* **PHASE 3: Tiered Scoring:** Custom algorithm calculates a 100-pt score and assigns Tiers (A+ to D).\n* **PHASE 4: Smart Routing:** - **Qualified (70+):** Syncs to HubSpot CRM, archives to 'Qualified' Drive, and alerts Slack.\n - **Rejected (<70):** Sends personalized feedback email and archives to 'Rejected' Drive.\n* **PHASE 5: Analytics:** Logs funnel metrics (time-to-hire, skill trends) to PostgreSQL.\n\n### \ud83d\udccb Setup\n1. **Drive:** Create 'Qualified' and 'Rejected' folders.\n2. **CRM:** Connect HubSpot App Token.\n3. **DB:** Prepare `candidate_applications` table in Postgres.\n\n**Metrics:** `Qualification_Score`, `Skill_Match_%`, `Funnel_Conversion`."
},
"typeVersion": 1
},
{
"id": "73ab0fd9-de75-4100-84bb-dfdb49025579",
"name": "Gmail Trigger",
"type": "n8n-nodes-base.gmailTrigger",
"position": [
-528,
1568
],
"parameters": {
"filters": {},
"pollTimes": {
"item": [
{
"mode": "everyMinute"
}
]
}
},
"credentials": {
"gmailOAuth2": {
"name": "<your credential>"
}
},
"typeVersion": 1.3
}
],
"connections": {
"Gmail Trigger": {
"main": [
[
{
"node": "Code: Pre-Validation",
"type": "main",
"index": 0
}
]
]
},
"Code: Pre-Validation": {
"main": [
[
{
"node": "IF: Valid PDF Attachment?",
"type": "main",
"index": 0
}
]
]
},
"Slack: Rejection Log": {
"main": [
[
{
"node": "Merge: Rejected Path",
"type": "main",
"index": 1
}
]
]
},
"Gmail: Send Rejection": {
"main": [
[
{
"node": "Slack: Rejection Log",
"type": "main",
"index": 0
}
]
]
},
"Merge: Qualified Path": {
"main": [
[
{
"node": "Code: Analytics Calculator",
"type": "main",
"index": 0
}
]
]
},
"Code: AI Resume Parser": {
"main": [
[
{
"node": "PostgreSQL: Log Application",
"type": "main",
"index": 0
},
{
"node": "IF: Qualified Candidate?",
"type": "main",
"index": 0
}
]
]
},
"Slack: Qualified Alert": {
"main": [
[
{
"node": "Merge: Qualified Path",
"type": "main",
"index": 0
}
]
]
},
"HubSpot: Create Contact": {
"main": [
[
{
"node": "Slack: Qualified Alert",
"type": "main",
"index": 0
}
]
]
},
"IF: Qualified Candidate?": {
"main": [
[
{
"node": "HubSpot: Create Contact",
"type": "main",
"index": 0
},
{
"node": "Google Drive: Archive Qualified",
"type": "main",
"index": 0
}
],
[
{
"node": "Google Drive: Archive Rejected",
"type": "main",
"index": 0
},
{
"node": "Gmail: Send Rejection",
"type": "main",
"index": 0
}
]
]
},
"IF: Valid PDF Attachment?": {
"main": [
[
{
"node": "PDF to Text: Extract Content",
"type": "main",
"index": 0
}
]
]
},
"Code: Analytics Calculator": {
"main": [
[
{
"node": "PostgreSQL: Store Analytics",
"type": "main",
"index": 0
}
]
]
},
"PDF to Text: Extract Content": {
"main": [
[
{
"node": "Code: AI Resume Parser",
"type": "main",
"index": 0
}
]
]
},
"Google Drive: Archive Rejected": {
"main": [
[
{
"node": "Merge: Rejected Path",
"type": "main",
"index": 0
}
]
]
},
"Google Drive: Archive Qualified": {
"main": [
[
{
"node": "Merge: Qualified Path",
"type": "main",
"index": 1
}
]
]
}
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
gmailOAuth2googleDriveOAuth2ApihtmlcsstopdfApihubspotAppToken
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This is an elite enterprise-grade solution for Talent Acquisition and HR Ops teams. It automates the high-volume task of resume screening by transforming unstructured PDF applications into structured candidate profiles. Leveraging an advanced PDF-to-JSON parsing engine and a…
Source: https://n8n.io/workflows/13034/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This template is built to be customized for your specific needs. This template has the core logic and n8n node specific references sorted to work with dynamic file names throughout the workflow. Store
This workflow runs every Monday at 8 AM and automatically monitors your Jira project, measures progress against the active sprint, and delivers a structured report to stakeholders — with zero manual e
Receive any business document via email. The attachment is automatically classified (Invoice, Contract, or Purchase Order) using easybits Extractor, then routed down the correct path where a second Ex
📘 Description
📩🤖 This workflow automatically processes emails received in Gmail, extracts their attachments, and organizes them into specific folders in Google Drive based on the sender's email address.