This workflow follows the Emailsend → Postgres 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 →
{
"name": "My workflow (fixed)",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "job-match-trigger",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
256,
0
],
"id": "b593f0f2-4419-4a47-b21e-6fc3077a1a14",
"name": "Webhook1"
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT id, name, email, parsed_skill, resume_text, educational_attainment\nFROM users\nWHERE role = 'Seeker'\n AND resume_text IS NOT NULL\n AND LENGTH(TRIM(resume_text)) > 10;",
"options": {}
},
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [
464,
0
],
"id": "ce6f971a-f76f-4504-84e2-f09107dd615d",
"name": "Execute a SQL query1",
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "const webhookItem = $('Webhook1').first()?.json ?? {};\nconst job = webhookItem.body ?? webhookItem;\nconst event = String(job?.event || '').toLowerCase();\n\nif (event !== 'job_created') {\n return [];\n}\n\nif (!job || !job.job_id) {\n throw new Error(`Missing job_id from webhook payload. Keys: ${Object.keys(job || {}).join(', ')}`);\n}\n\nconst jobId = Number(job.job_id);\nconst jobTitle = String(job.title || '').toLowerCase();\nconst jobTitleRaw = String(job.title || 'this job');\nconst requiredEducationRaw = String(job.educational_attainment_required || '').trim();\n\nconst normalizeText = (value) => String(value || '').toLowerCase().trim();\nconst normalizeNoSymbols = (value) => normalizeText(value).replace(/[^a-z0-9\\s]/g, ' ').replace(/\\s+/g, ' ').trim();\n\nconst educationLevels = [\n { level: 'doctorate', rank: 5, keywords: ['doctorate', 'doctoral', 'phd', 'doctor of philosophy'] },\n { level: 'masters', rank: 4, keywords: ['master', 'masters', \"master's\", 'm.s', 'ms', 'm.a', 'ma', 'mba'] },\n { level: 'bachelors', rank: 3, keywords: ['bachelor', 'bachelors', \"bachelor's\", 'b.s', 'bs', 'b.a', 'ba', 'college graduate', 'college grad', 'degree'] },\n { level: 'associate', rank: 2, keywords: ['associate degree', 'associate'] },\n { level: 'vocational', rank: 2, keywords: ['vocational', 'tesda', 'certificate', 'technical-vocational', 'nc ii', 'nc iii'] },\n { level: 'highschool', rank: 1, keywords: ['high school', 'secondary', 'senior high', 'shs'] },\n];\n\nconst inferEducationLevel = (value) => {\n const normalized = normalizeNoSymbols(value);\n if (!normalized || ['any', 'not specified', 'none', 'n a', 'na'].includes(normalized)) {\n return null;\n }\n\n for (const item of educationLevels) {\n if (item.keywords.some((keyword) => normalized.includes(normalizeNoSymbols(keyword)))) {\n return item.level;\n }\n }\n\n return null;\n};\n\nconst levelRank = (level) => {\n const found = educationLevels.find((item) => item.level === level);\n return found ? found.rank : null;\n};\n\nconst isEducationRequired = (() => {\n const normalized = normalizeText(requiredEducationRaw);\n return normalized !== '' && !['any', 'not specified', 'none', 'n/a', 'na'].includes(normalized);\n})();\n\nconst requiredEducationLevel = inferEducationLevel(requiredEducationRaw);\n\nlet reqSkillsRaw = job.skills_required ?? job.required_skills ?? [];\nif (typeof reqSkillsRaw === 'string') {\n try {\n reqSkillsRaw = JSON.parse(reqSkillsRaw);\n } catch {\n reqSkillsRaw = [reqSkillsRaw];\n }\n}\nif (!Array.isArray(reqSkillsRaw) && typeof reqSkillsRaw === 'object' && reqSkillsRaw !== null) {\n reqSkillsRaw = Object.values(reqSkillsRaw);\n}\nconst reqSkills = reqSkillsRaw\n .map((s) => String(s).toLowerCase().trim())\n .filter(Boolean);\nconst requiredSkillsText = reqSkills.length > 0 ? reqSkills.join(', ') : 'Not specified';\n\nconst candidates = $input.all().map((item) => item.json);\nconst matchedCandidates = [];\n\nfor (const candidate of candidates) {\n const resume = String(candidate.resume_text || '').toLowerCase();\n const candidateEducationRaw = String(candidate.educational_attainment || '').trim();\n let candidateEducationLevel = inferEducationLevel(candidateEducationRaw);\n if (!candidateEducationLevel) {\n candidateEducationLevel = inferEducationLevel(resume);\n }\n\n if (isEducationRequired) {\n let educationQualified = false;\n\n if (requiredEducationLevel && candidateEducationLevel) {\n educationQualified = Number(levelRank(candidateEducationLevel) || 0) >= Number(levelRank(requiredEducationLevel) || 0);\n } else {\n const requiredNormalized = normalizeNoSymbols(requiredEducationRaw);\n const candidateNormalized = normalizeNoSymbols(candidateEducationRaw);\n educationQualified = candidateNormalized !== '' && (\n candidateNormalized === requiredNormalized ||\n candidateNormalized.includes(requiredNormalized)\n );\n }\n\n if (!educationQualified) {\n const seekerEducationLabel = candidateEducationRaw || 'Not specified';\n const reason = `Auto-disqualified: requires ${requiredEducationRaw}, applicant has ${seekerEducationLabel}.`;\n const seekerName = String(candidate.name || 'Jobseeker');\n\n matchedCandidates.push({\n job_id: jobId,\n user_id: candidate.id,\n name: seekerName,\n email: candidate.email,\n matchScore: 0,\n inferred_edu: candidateEducationLevel || 'unknown',\n disqualified: true,\n reason,\n email_subject: `Not Qualified for ${jobTitleRaw}`,\n email_body: `Hi ${seekerName},<br/><br/>You are not qualified for <b>${jobTitleRaw}</b> because the job requires <b>${requiredEducationRaw}</b> and your profile currently shows <b>${seekerEducationLabel}</b>.<br/><br/>Please apply to jobs that match your educational attainment.<br/><br/>Best regards,<br/>CityJobLink Team`,\n });\n\n continue;\n }\n }\n\n if (resume.length < 5) {\n continue;\n }\n\n let score = 0;\n const matchedDetails = [];\n\n if (candidateEducationLevel) {\n matchedDetails.push(`Education level: ${candidateEducationLevel}`);\n }\n\n const alignmentKeywords = jobTitle.split(/\\s+/).filter((word) => word.length > 3);\n if (alignmentKeywords.some((word) => resume.includes(word))) {\n score += 30;\n matchedDetails.push(`Field aligned with Job Title: \"${jobTitle}\"`);\n }\n\n let skillCount = 0;\n for (const skill of reqSkills) {\n if (resume.includes(skill)) {\n skillCount++;\n }\n }\n\n const skillScore = reqSkills.length > 0 ? (skillCount / reqSkills.length) * 70 : 0;\n score += skillScore;\n if (skillCount > 0) {\n matchedDetails.push(`${skillCount} skills found in resume text`);\n }\n\n const matchScore = Math.round(Math.min(score, 100));\n const seekerName = String(candidate.name || 'Jobseeker');\n\n matchedCandidates.push({\n job_id: jobId,\n user_id: candidate.id,\n name: seekerName,\n email: candidate.email,\n matchScore,\n inferred_edu: candidateEducationLevel || 'unknown',\n disqualified: false,\n reason: matchedDetails.length > 0 ? matchedDetails.join(' | ') : 'No direct matches found',\n email_subject: `${matchScore}% Match! New Job Alert: ${jobTitleRaw}`,\n email_body: `Hi ${seekerName},<br/><br/>Great news! Our matching engine found a new job that fits your profile.<br/><br/><b>Match Score:</b> ${matchScore}%<br/><b>Required Skills:</b> ${requiredSkillsText}<br/><b>Match Reason:</b> ${matchedDetails.length > 0 ? matchedDetails.join(' | ') : 'General profile fit'}<br/><br/>Log in to CityJobLink to apply now!<br/><br/>Best regards,<br/>CityJobLink Team`,\n });\n}\n\nmatchedCandidates.sort((a, b) => Number(b.matchScore) - Number(a.matchScore));\n\nreturn matchedCandidates.map((match) => ({ json: match }));"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
640,
0
],
"id": "8ce873ee-ab87-4550-97aa-0b442f737276",
"name": "Code in JavaScript1"
},
{
"parameters": {
"schema": {
"__rl": true,
"mode": "list",
"value": "public"
},
"table": {
"__rl": true,
"value": "job_matches",
"mode": "list",
"cachedResultName": "job_matches"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"job_id": "={{ $json.job_id }}",
"user_id": "={{ $json.user_id }}",
"match_score": "={{ Number($json.matchScore) }}",
"match_reasons": "={{ $json.reason }}"
},
"schema": [
{
"id": "job_id",
"displayName": "job_id",
"required": true,
"defaultMatch": false,
"display": true,
"type": "number",
"canBeUsedToMatch": true
},
{
"id": "user_id",
"displayName": "user_id",
"required": true,
"defaultMatch": false,
"display": true,
"type": "number",
"canBeUsedToMatch": true
},
{
"id": "match_score",
"displayName": "match_score",
"required": true,
"defaultMatch": false,
"display": true,
"type": "number",
"canBeUsedToMatch": true
},
{
"id": "match_reasons",
"displayName": "match_reasons",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
}
],
"attemptToConvertTypes": true,
"convertFieldsToString": false
},
"options": {}
},
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [
848,
0
],
"id": "41fb71e5-ff72-4e7c-98f1-30cbf8256aa4",
"name": "Insert rows in a table",
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "return $input.all().filter((item) => Number(item?.json?.matchScore ?? 0) >= 50);"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
848,
160
],
"id": "5d6eb81b-3a5e-4cf3-bdc3-2d315b86d1e4",
"name": "Filter Email 50+"
},
{
"parameters": {
"fromEmail": "cityjoblink.direct@gmail.com",
"toEmail": "={{ $json.email }}",
"subject": "={{ Number($json.matchScore ?? 0) + '% Match: ' + String(($('Webhook1').first()?.json?.body?.title || $('Webhook1').first()?.json?.title || 'Job Opportunity')) + ' | New Job Alert' }}",
"emailFormat": "html",
"html": "={{ (() => { const webhook = $('Webhook1').first()?.json ?? {}; const job = webhook.body ?? webhook; const jobTitle = String(job.title || 'Job Opportunity'); const seekerName = String($json.name || 'Jobseeker'); const matchScore = Number($json.matchScore ?? 0); const reason = String($json.reason || '').trim(); const inferredEduRaw = String($json.inferred_edu || '').trim(); const degreeLevel = inferredEduRaw && inferredEduRaw !== 'unknown' ? inferredEduRaw.charAt(0).toUpperCase() + inferredEduRaw.slice(1) : 'Not specified'; const skillCountMatch = reason.match(/(\\d+)\\s+skills?/i); const skillCount = skillCountMatch ? skillCountMatch[1] : '0'; const matchedSkills = reason || (skillCount !== '0' ? `${skillCount} matching skills` : 'Not specified'); const applyUrl = 'http://172.20.10.3:5173/?jobId=' + encodeURIComponent(String($json.job_id || '')); return `Hi ${seekerName},<br/><br/>A new job opening has been posted that matches your profile.<br/><br/><b>Match Details</b><br/>Position: ${jobTitle}<br/><br/>Match Score: ${matchScore}%<br/><br/>Matched Skills: ${matchedSkills}<br/><br/><b>Match Logic:</b><br/>Based on your ${degreeLevel} and ${skillCount} matching skills found in your resume.<br/><br/><b>Action Required:</b><br/>Review the full job description and apply via the link below:<br/><br/><a href='${applyUrl}' style='display:inline-block;padding:10px 16px;background:#2563eb;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;'>Apply on CityJobLink</a><br/><br/>CityJobLink Team`; })() }}",
"options": {}
},
"type": "n8n-nodes-base.emailSend",
"typeVersion": 2.1,
"position": [
1024,
0
],
"id": "3e5d1a3d-e212-44dd-b192-5694908970f4",
"name": "Send an Email1",
"credentials": {
"smtp": {
"name": "<your credential>"
}
}
}
],
"connections": {
"Webhook1": {
"main": [
[
{
"node": "Execute a SQL query1",
"type": "main",
"index": 0
}
]
]
},
"Execute a SQL query1": {
"main": [
[
{
"node": "Code in JavaScript1",
"type": "main",
"index": 0
}
]
]
},
"Code in JavaScript1": {
"main": [
[
{
"node": "Insert rows in a table",
"type": "main",
"index": 0
},
{
"node": "Filter Email 50+",
"type": "main",
"index": 0
}
]
]
},
"Filter Email 50+": {
"main": [
[
{
"node": "Send an Email1",
"type": "main",
"index": 0
}
]
]
},
"Insert rows in a table": {
"main": [
[]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"availableInMCP": false
},
"versionId": "b3969b37-a6b1-42a5-b4e2-72ddb59447fd",
"meta": {
"templateCredsSetupCompleted": true
},
"tags": []
}
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.
postgressmtp
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
My workflow (fixed). Uses postgres, emailSend. Webhook trigger; 6 nodes.
Source: https://github.com/Jeperlyn/cityjoblink/blob/0aaa9077b6f12962b46d79301b0a95411a160e4c/n8n/my-workflow-fixed.json — 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 workflow automates data maturity evaluation to measure how well an organization uses data to create value by capturing assessment data through forms or APIs, processing and scoring responses usin
This n8n workflow automates the transformation of raw text ideas into structured visual diagrams and content assets using NapkinAI.
Receive request via webhook with customer question Analyze sentiment and detect urgency using JavaScript Send urgent alerts to Slack for critical cases Search knowledge base and fetch conversation his
PURPOSE: Automatically send professional appointment reminders via email and SMS to reduce no-shows and improve patient experience.