diff --git a/docs/setup-guide.md b/docs/setup-guide.md index c5f1369..fbfa05e 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -128,6 +128,57 @@ The n8n Kickstarter script (`deploy-n8n-v2.ts`) supports credentials setup and d ``` *Note: The script dynamically detects if a credential exists in n8n. If found, it safely reuses the credential to avoid entering keys repeatedly.* +### D. Email Ingestion & Notification Workflows +To deploy the email-based workflows (receiving CVs via IMAP and notifying candidates via SMTP on stage changes), run the dedicated email deployment script: +```bash +npx tsx scripts/deploy-email-automation.ts +``` + +This script will: +1. Create or update `Semillero2_IMAP_V2` credentials in n8n (utilizes standard secure port `993`). +2. Create or update `Semillero2_SMTP` credentials in n8n (utilizes `.env` SMTP variables). +3. Deploy and activate **Semillero2: Email Ingestion Listener** (listens for emails with PDF attachments, extracts text, and triggers candidate evaluation). +4. Deploy and activate **Semillero2: Stage Change Email Notifier** (listens for database webhooks on stage updates and sends templated HTML emails to candidates). + +### E. Supabase Database Webhook Setup (Stage Changes) +The email notifier workflow is triggered by a Supabase Database Webhook when candidate stages are updated. +Run the following SQL in your Supabase SQL Editor to enable the trigger: +```sql +-- Enable pg_net extension for network calls +CREATE EXTENSION IF NOT EXISTS pg_net; + +-- Create trigger function +CREATE OR REPLACE FUNCTION notify_interview_stage_change() +RETURNS TRIGGER AS $$ +DECLARE + payload_body TEXT; +BEGIN + IF (TG_OP = 'INSERT' OR OLD.stage IS DISTINCT FROM NEW.stage) THEN + payload_body := jsonb_build_object( + 'old_record', CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE to_jsonb(OLD) END, + 'new_record', to_jsonb(NEW) + )::text; + + PERFORM net.http_post( + url := 'https://n8n.gaboggamer.online/webhook/stage-changed', + headers := '{"Content-Type": "application/json"}'::jsonb, + body := payload_body + ); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Drop existing trigger if it exists +DROP TRIGGER IF EXISTS tr_interview_stage_change ON public.interviews; + +-- Bind trigger to public.interviews table +CREATE TRIGGER tr_interview_stage_change +AFTER INSERT OR UPDATE ON public.interviews +FOR EACH ROW +EXECUTE FUNCTION notify_interview_stage_change(); +``` + --- ## 3. Supported Webhooks / API Endpoints diff --git a/scripts/deploy-email-automation.ts b/scripts/deploy-email-automation.ts new file mode 100644 index 0000000..f47079b --- /dev/null +++ b/scripts/deploy-email-automation.ts @@ -0,0 +1,501 @@ +import * as fs from "fs"; +import * as path from "path"; + +// Load .env variables +const envPath = path.join(__dirname, "../.env"); +if (fs.existsSync(envPath)) { + const envContent = fs.readFileSync(envPath, "utf8"); + for (const line of envContent.split("\n")) { + const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)\s*$/); + if (match) { + const key = match[1]; + let value = match[2].trim(); + if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1); + else if (value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1); + process.env[key] = value; + } + } +} + +const N8N_HOST = process.env.N8N_HOST || "https://n8n.gaboggamer.online"; +const N8N_API_KEY = process.env.N8N_API_KEY; +const EMAIL_USER = process.env.EMAIL_USER; +const EMAIL_PASSWORD = process.env.EMAIL_PASSWORD; +const EMAIL_HOST = process.env.EMAIL_HOST; +const EMAIL_PORT = parseInt(process.env.EMAIL_PORT || "465", 10); +const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL; +const SUPABASE_SECRET_KEY = process.env.SUPABASE_SECRET_KEY; + +if (!N8N_API_KEY) { + console.error("Error: N8N_API_KEY is not defined in .env"); + process.exit(1); +} + +async function n8nRequest(endpoint: string, method: string = "GET", body?: any) { + const response = await fetch(`${N8N_HOST}${endpoint}`, { + method, + headers: { + "X-N8N-API-KEY": N8N_API_KEY!, + "Content-Type": "application/json", + }, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`n8n API request failed: ${response.status} ${response.statusText} - ${text}`); + } + + return response.json(); +} + +async function getOrCreateCredential(name: string, type: string, data: any) { + try { + const credsList = await n8nRequest("/api/v1/credentials"); + const existingCred = credsList.data.find((c: any) => c.name === name && c.type === type); + if (existingCred) { + console.log(`Reusing existing credential: ${name} (ID: ${existingCred.id})...`); + return existingCred.id; + } + + const newCred = await n8nRequest("/api/v1/credentials", "POST", { + name, + type, + data, + }); + console.log(`Created new credential: ${name} (ID: ${newCred.id})`); + return newCred.id; + } catch (err: any) { + console.error(`Error setting up credential ${name}:`, err.message); + process.exit(1); + } +} + +async function main() { + console.log("\n=============================================="); + console.log("DEPLOYING EMAIL AUTOMATION FOR N8N"); + console.log("=============================================="); + + // 1. Setup SMTP Credentials + const smtpCredId = await getOrCreateCredential("Semillero2_SMTP", "smtp", { + host: EMAIL_HOST, + port: EMAIL_PORT, + user: EMAIL_USER, + password: EMAIL_PASSWORD, + secure: true, + }); + + // 2. Setup IMAP Credentials + const imapCredId = await getOrCreateCredential("Semillero2_IMAP_V2", "imap", { + host: EMAIL_HOST, + port: 993, + user: EMAIL_USER, + password: EMAIL_PASSWORD, + secure: true, + }); + + // 3. Setup Supabase Credentials + const supabaseCredId = await getOrCreateCredential("Semillero2_Supabase", "supabaseApi", { + host: SUPABASE_URL, + serviceRole: SUPABASE_SECRET_KEY, + allowedHttpRequestDomains: "none", + }); + + // 4. Fetch Candidate Evaluation Workflow ID + const workflowsList = await n8nRequest("/api/v1/workflows"); + const evalWf = workflowsList.data.find( + (w: any) => w.name === "Semillero2: End-to-End Candidate Evaluation" + ); + if (!evalWf) { + console.error("Error: Could not find candidate evaluation workflow ID."); + process.exit(1); + } + const evalWfId = evalWf.id; + console.log(`Candidate Evaluation Workflow ID: ${evalWfId}`); + + // 5. Deploy / Update IMAP Ingestion Workflow + const imapWf = workflowsList.data.find( + (w: any) => w.name === "Semillero2: Email Ingestion Listener" + ); + + const imapWfDefinition = { + name: "Semillero2: Email Ingestion Listener", + settings: {}, + nodes: [ + { + parameters: { + postProcessAction: "markAsRead", + format: "resolved", + downloadAttachments: true, + options: { + customEmailConfig: '["UNSEEN"]', + }, + }, + id: "w2-imap-id", + name: "Email Trigger (IMAP)", + type: "n8n-nodes-base.emailReadImap", + typeVersion: 2, + position: [100, 300], + credentials: { + imap: { + id: imapCredId, + name: "Semillero2_IMAP_V2", + }, + }, + }, + { + parameters: { + operation: "pdf", + binaryPropertyName: "attachment_0", + }, + id: "w2-extract-id", + name: "Extract From PDF", + type: "n8n-nodes-base.extractFromFile", + typeVersion: 1, + position: [320, 300], + }, + { + parameters: { + jsCode: `const item = $input.first().json; +const text = item.text || ""; +const emailBody = $('Email Trigger (IMAP)').item.json.textHtml || $('Email Trigger (IMAP)').item.json.text || ""; +const fromEmail = $('Email Trigger (IMAP)').item.json.from.value[0].address; +const fromName = $('Email Trigger (IMAP)').item.json.from.value[0].name || fromEmail.split('@')[0]; +return [{ + json: { + name: fromName, + email: fromEmail, + text: \`Email Body:\\n\${emailBody}\\n\\nResume Text:\\n\${text}\` + } +}];`, + }, + id: "w2-format-id", + name: "Format Email Data", + type: "n8n-nodes-base.code", + typeVersion: 2, + position: [540, 300], + }, + { + parameters: { + workflowId: evalWfId, + options: {}, + }, + id: "w2-exec-id", + name: "Execute Workflow 3", + type: "n8n-nodes-base.executeWorkflow", + typeVersion: 1, + position: [760, 300], + }, + ], + connections: { + "Email Trigger (IMAP)": { + main: [ + [ + { + node: "Extract From PDF", + type: "main", + index: 0, + }, + ], + ], + }, + "Extract From PDF": { + main: [ + [ + { + node: "Format Email Data", + type: "main", + index: 0, + }, + ], + ], + }, + "Format Email Data": { + main: [ + [ + { + node: "Execute Workflow 3", + type: "main", + index: 0, + }, + ], + ], + }, + }, + }; + + let imapDeployResult; + if (imapWf) { + if (imapWf.isArchived) { + console.log(`Deleting archived IMAP workflow (ID: ${imapWf.id})...`); + await n8nRequest(`/api/v1/workflows/${imapWf.id}`, "DELETE"); + console.log("Creating new IMAP workflow..."); + imapDeployResult = await n8nRequest("/api/v1/workflows", "POST", imapWfDefinition); + } else { + console.log(`Updating existing IMAP workflow (ID: ${imapWf.id})...`); + imapDeployResult = await n8nRequest(`/api/v1/workflows/${imapWf.id}`, "PUT", imapWfDefinition); + } + } else { + console.log("Creating new IMAP workflow..."); + imapDeployResult = await n8nRequest("/api/v1/workflows", "POST", imapWfDefinition); + } + + // Activate IMAP Ingestion Workflow + console.log(`Activating IMAP Ingestion workflow (ID: ${imapDeployResult.id})...`); + await n8nRequest(`/api/v1/workflows/${imapDeployResult.id}/activate`, "POST"); + + // 6. Deploy / Update Stage Change Email Notifier + const notifierWf = workflowsList.data.find( + (w: any) => w.name === "Semillero2: Stage Change Email Notifier" + ); + + const notifierWfDefinition = { + name: "Semillero2: Stage Change Email Notifier", + settings: {}, + nodes: [ + { + parameters: { + httpMethod: "POST", + path: "stage-changed", + responseMode: "responseNode", + options: {}, + }, + id: "notifier-webhook-id", + name: "Stage Changed Webhook", + type: "n8n-nodes-base.webhook", + typeVersion: 1.1, + position: [100, 300], + }, + { + parameters: { + resource: "row", + operation: "get", + tableId: "candidates", + rowId: "={{ $('Stage Changed Webhook').item.json.body.new_record.candidate_id }}", + }, + id: "notifier-get-cand-id", + name: "Get Candidate", + type: "n8n-nodes-base.supabase", + typeVersion: 1, + position: [320, 300], + credentials: { + supabaseApi: { + id: supabaseCredId, + name: "Semillero2_Supabase", + }, + }, + }, + { + parameters: { + resource: "row", + operation: "get", + tableId: "jobs", + rowId: "={{ $('Stage Changed Webhook').item.json.body.new_record.job_id }}", + }, + id: "notifier-get-job-id", + name: "Get Job", + type: "n8n-nodes-base.supabase", + typeVersion: 1, + position: [540, 300], + credentials: { + supabaseApi: { + id: supabaseCredId, + name: "Semillero2_Supabase", + }, + }, + }, + { + parameters: { + jsCode: `const candidate = $('Get Candidate').first().json; +const candidateName = candidate.name; +const contactInfo = candidate.contact_info || {}; +const candidateEmail = contactInfo.email || "unknown@example.com"; +const jobTitle = $('Get Job').first().json.title; +const oldRecord = $('Stage Changed Webhook').first().json.body.old_record; +const oldStage = oldRecord ? oldRecord.stage : 'None'; +const newStage = $('Stage Changed Webhook').first().json.body.new_record.stage; + +let subject = ''; +let html = ''; + +if (newStage === 'Screening') { + subject = \`Application Received: \${jobTitle}\`; + html = \`

Hello \${candidateName},

+

Thank you for applying to the \${jobTitle} position. We have received your CV and are currently reviewing it.

+

Best regards,
The Recruitment Team

\`; +} else if (newStage === 'Technical') { + subject = \`Next Steps - Technical Interview: \${jobTitle}\`; + html = \`

Hello \${candidateName},

+

Great news! Your application for the \${jobTitle} position has progressed to the Technical Interview stage.

+

Our team will contact you shortly to schedule the interview. Please prepare by reviewing core concepts for the role.

+

Best regards,
The Recruitment Team

\`; +} else if (newStage === 'Cultural') { + subject = \`Next Steps - Cultural Interview: \${jobTitle}\`; + html = \`

Hello \${candidateName},

+

We are pleased to invite you to the Cultural Fit Interview for the \${jobTitle} role.

+

This conversation will be a casual discussion to get to know you better and answer any questions you have about our culture and team.

+

Best regards,
The Recruitment Team

\`; +} else if (newStage === 'Offer') { + subject = \`Job Offer - \${jobTitle}!\`; + html = \`

Hello \${candidateName},

+

Congratulations! We are thrilled to extend a Job Offer for the \${jobTitle} position.

+

We will send the official offer letter and details via email shortly. We look forward to having you join us!

+

Best regards,
The Hiring Team

\`; +} else if (newStage === 'Hired') { + subject = \`Welcome to the Team!\`; + html = \`

Hello \${candidateName},

+

Welcome aboard! You have been officially Hired for the \${jobTitle} position.

+

Our onboarding team will reach out with the next steps for your first day.

+

Best regards,
The Team

\`; +} else if (newStage === 'Rejected') { + subject = \`Application Update: \${jobTitle}\`; + html = \`

Hello \${candidateName},

+

Thank you for your interest in the \${jobTitle} position and for taking the time to interview with us.

+

Unfortunately, we have decided to proceed with other candidates whose profiles more closely align with our current needs.

+

We will keep your profile in our database for future opportunities.

+

Best regards,
The Recruitment Team

\`; +} else { + subject = \`Application Status Update: \${jobTitle}\`; + html = \`

Hello \${candidateName},

+

The status of your application for the \${jobTitle} position has been updated to: \${newStage}.

+

Best regards,
The Recruitment Team

\`; +} + +return [{ + json: { + toEmail: candidateEmail, + subject, + html + } +}];`, + }, + id: "notifier-code-id", + name: "Format Notification Email", + type: "n8n-nodes-base.code", + typeVersion: 2, + position: [760, 300], + }, + { + parameters: { + fromEmail: '"Recruitment Team" ', + toEmail: "={{ $json.toEmail }}", + subject: "={{ $json.subject }}", + html: "={{ $json.html }}", + options: {}, + }, + id: "notifier-send-id", + name: "Send Notification Email", + type: "n8n-nodes-base.emailSend", + typeVersion: 2.1, + position: [980, 300], + credentials: { + smtp: { + id: smtpCredId, + name: "Semillero2_SMTP", + }, + }, + }, + { + parameters: { + options: {}, + }, + id: "notifier-respond-id", + name: "Respond to Webhook", + type: "n8n-nodes-base.respondToWebhook", + typeVersion: 1.1, + position: [1200, 300], + }, + ], + connections: { + "Stage Changed Webhook": { + main: [ + [ + { + node: "Get Candidate", + type: "main", + index: 0, + }, + ], + ], + }, + "Get Candidate": { + main: [ + [ + { + node: "Get Job", + type: "main", + index: 0, + }, + ], + ], + }, + "Get Job": { + main: [ + [ + { + node: "Format Notification Email", + type: "main", + index: 0, + }, + ], + ], + }, + "Format Notification Email": { + main: [ + [ + { + node: "Send Notification Email", + type: "main", + index: 0, + }, + ], + ], + }, + "Send Notification Email": { + main: [ + [ + { + node: "Respond to Webhook", + type: "main", + index: 0, + }, + ], + ], + }, + }, + }; + + let notifierDeployResult; + if (notifierWf) { + if (notifierWf.isArchived) { + console.log(`Deleting archived Notifier workflow (ID: ${notifierWf.id})...`); + await n8nRequest(`/api/v1/workflows/${notifierWf.id}`, "DELETE"); + console.log("Creating new Notifier workflow..."); + notifierDeployResult = await n8nRequest("/api/v1/workflows", "POST", notifierWfDefinition); + } else { + console.log(`Updating existing Notifier workflow (ID: ${notifierWf.id})...`); + notifierDeployResult = await n8nRequest(`/api/v1/workflows/${notifierWf.id}`, "PUT", notifierWfDefinition); + } + } else { + console.log("Creating new Notifier workflow..."); + notifierDeployResult = await n8nRequest("/api/v1/workflows", "POST", notifierWfDefinition); + } + + // Activate Notifier Workflow + console.log(`Activating Notifier workflow (ID: ${notifierDeployResult.id})...`); + await n8nRequest(`/api/v1/workflows/${notifierDeployResult.id}/activate`, "POST"); + + const notifierWebhookUrl = `${N8N_HOST}/webhook/stage-changed`; + console.log("\n=============================================="); + console.log("EMAIL AUTOMATION SETUP COMPLETE"); + console.log("=============================================="); + console.log(`IMAP Workflow ID: ${imapDeployResult.id}`); + console.log(`Notifier Workflow ID: ${notifierDeployResult.id}`); + console.log(`Notifier Webhook URL: ${notifierWebhookUrl}`); + console.log("=============================================="); +} + +main().catch((err) => { + console.error("Email automation setup failed:", err); + process.exit(1); +});