Build a CV Email Router with Gemini

Build a Gmail router that analyzes PDF CVs with Gemini and drafts replies.

Introduction

30 Second Summary

Application emails can quickly turn an inbox into a repetitive sorting queue. Similar subject lines also make it difficult to identify the strongest evidence without opening every attachment.

In this project, you will build a browser-based CV email router with Gmail, Google Apps Script, and the Gemini API. The router will classify synthetic applications into two internal routes while keeping every reply as an unsent draft for human review.

What You'll Build

You will watch a synthetic PDF application move from the intake label to an evidence-based route with an editable draft waiting for your review.

By the end of this project, you'll have:

  • A labeled intake that discovers synthetic application emails with attached PDF CVs. You can confirm each match from its logged subject plus filename.
  • Structured JSON evidence showing whether each CV explicitly demonstrates SQL, spreadsheet skills, or a completed data project.
  • An automatic 10-minute routing loop that applies either CV-Router-Accepted or CV-Router-Rejected. Each result leaves an editable reply draft unsent for human review.
  • Secret Mission: A deliberate || bug that produces a false acceptance. You will restore && to verify the corrected rejection.

Are there any prerequisites?

No prior setup is required because every tool runs in your browser. This prototype uses synthetic applicant details because Gemini API free-tier content may be used to improve Google products.

Before We Start

The router automates four tasks: finding labeled synthetic PDF applications, extracting job-related evidence, applying internal routing labels, and creating unsent drafts. A human reviewer inspects the PDF, extracted evidence, route, and draft before deciding or communicating any employment outcome.

Set Up the Synthetic Workspace

Employment documents contain personal information. This training workspace uses only synthetic applications because free-tier content sent to the Gemini API may be used to improve Google products.

Google Apps Script will hold the router in your browser.

You will secure its API key before preparing two test applications in Gmail. The contrasting CVs make the router's later results visible.

In this step, get ready to:
  • Create a standalone Apps Script project with a clear project time zone.
  • Store a Gemini API key in script properties without exposing it in code.
  • Prepare two labeled Gmail test threads with contrasting synthetic PDF CVs.
Create the Apps Script project

A standalone project gives the router its own settings. Its project time zone gives every proposed interview slot a clear meaning.

  • Go to script.google.com in your browser.
  • Sign in with the Google account you use for Gmail.
  • Click New Project.
  • Click Untitled project at the top.
  • Enter CV Application Router as the project name.
  • Click Rename.
  • Click Project Settings in the left sidebar.
  • Select your local time zone in the Time zone section.

That's the project foundation in place. You should see CV Application Router at the top of the editor.

Why does the time zone matter?

The router later proposes an interview slot using this project time zone. A clear setting prevents the slot from carrying an ambiguous local time.

Secure the Gemini API key

Google AI Studio creates the credential that Apps Script uses to call Gemini. Script properties keep the credential outside your source code.

Protect the API key

A live API key behaves like a password. Keep it inside script properties.

Never paste it into Code.gs. Never include its value in a screenshot.

  • Open Google AI Studio in a new browser tab.
  • Sign in with the same Google account.
  • Accept the terms if Google AI Studio asks you to continue.
  • Click Create API key.
  • Choose the default project if Google AI Studio asks where to create the key.
  • Leave billing unattached if Google AI Studio offers a billing option.
  • Copy the generated key.

The credential now exists. Its next stop is the protected settings area of your script project.

  • Switch back to the CV Application Router project from earlier.
  • Click Project Settings in the left sidebar.
  • Click Add script property under Script Properties.
  • Enter GEMINI_API_KEY in the Property field.
  • Paste the key into the Value field.
  • Click Save script properties.

Your key is secured outside the code. You should see GEMINI_API_KEY listed as a script property without needing to reveal its value.

Can't see the script property?

Confirm that Project Settings belongs to CV Application Router. The property name must match GEMINI_API_KEY exactly.

Check the property setup without sharing the key value by asking for help here: Help me troubleshoot my Apps Script property setup.

Prepare the synthetic Gmail intake

Google Docs gives you two controlled CVs for testing the evidence rules. Each one-page document becomes a PDF attachment for the intake label.

  • Open Google Docs in a new browser tab.
  • Sign in with the same Google account.
  • Create a blank document.
  • Name the document CV A.
  • Add a clearly fictional applicant identity.
  • State that the applicant used SQL for data analysis.
  • State that the applicant used Excel or Google Sheets for spreadsheet work.
  • Describe one completed data-analysis project with a clear objective plus a measurable result.

Keep the evidence job-related

Keep every detail fictional. Include only skills plus completed work that relates to the Junior Data Analyst role.

Leave out photos plus protected or sensitive characteristics. The later analysis only needs explicit job evidence.

  • Keep CV A to one page.
  • Click File in the top menu.
  • Select Download.
  • Click PDF Document (.pdf).

You should find the downloaded CV A PDF in your browser's downloads.

  • Return to the Google Docs home screen.
  • Create another blank document.
  • Name the document CV B.
  • Add a different fictional applicant identity.
  • Describe spreadsheet work using Excel or Google Sheets.
  • Omit explicit SQL evidence.
  • Omit evidence of a completed data-analysis project.
  • Keep CV B to one page.
  • Click File in the top menu.
  • Select Download.
  • Click PDF Document (.pdf).

You should now have two one-page PDF files. Their evidence differs in a deliberate way.

Why use contrasting CVs?

CV A supplies every required evidence type. CV B supplies spreadsheet evidence only.

That contrast gives the later routing condition a predictable strong case plus a predictable weak case.

  • Open Gmail in a new browser tab.
  • Sign in with the same Google account.
  • Click Compose.
  • Enter your own Gmail address in the To field.
  • Enter Synthetic Junior Data Analyst Application - CV A in the subject field.
  • Type Please find my synthetic Junior Data Analyst application attached. in the message body.
  • Attach the downloaded CV A PDF.
  • Click Send.

The first synthetic application is now a self-sent Gmail thread with one PDF attachment.

  • Click Compose again.
  • Enter your own Gmail address in the To field.
  • Enter Synthetic Junior Data Analyst Application - CV B in the subject field.
  • Type Please find my synthetic Junior Data Analyst application attached. in the message body.
  • Attach the downloaded CV B PDF.
  • Click Send.

Both test applications are in Gmail. The intake label now gives the future router a controlled place to find them.

  • Click Create new label next to Labels in the left sidebar.
  • Enter CV-Router-Inbox as the label name.
  • Click Create.
  • Select the checkboxes beside both synthetic application threads.
  • Click Labels in the Gmail toolbar.
  • Select CV-Router-Inbox to apply the label.

Before you check, where do you expect to find proof that the intake is ready?

  • Open CV-Router-Inbox from the Gmail sidebar.
  • Confirm the label view contains exactly two self-sent threads.
  • Open the CV A thread to confirm it contains one PDF attachment.
  • Return to the CV-Router-Inbox label.
  • Open the CV B thread to confirm it contains one PDF attachment.
  • Switch back to the Apps Script project from earlier.
  • Click Project Settings.
  • Confirm that GEMINI_API_KEY remains listed without revealing its value.

You should see two threads under CV-Router-Inbox. Each thread should contain one synthetic PDF attachment.

Project Settings should show the GEMINI_API_KEY property. No key value should appear in Code.gs.

Your synthetic workspace is ready. Next, you'll make Apps Script discover both labeled PDF applications without opening them manually.

Find the Application Emails

Your two synthetic CV applications are waiting under the CV-Router-Inbox label in Gmail. Opening each thread manually would turn this router into another inbox chore.

This step gives Google Apps Script a Gmail search query that finds pending PDF applications. The run log will show each matching subject beside its attachment filename.

In this step, get ready to:
  • Define the router settings and Gmail intake query.
  • Find the newest PDF-bearing message in each matching thread.
  • Run the inbox preview and compare its log with your two test applications.
Define the intake search

A central configuration object keeps the router's labels and limits in one place. The search helper uses those settings to find a controlled batch of pending applications.

  • Switch back to Code.gs in the CV Application Router project.
  • Select any starter code in Code.gs.
  • Replace the selected code by pasting this configuration object:
const CONFIG = {
  intakeLabel: 'CV-Router-Inbox',
  acceptedLabel: 'CV-Router-Accepted',
  rejectedLabel: 'CV-Router-Rejected',
  processedLabel: 'CV-Router-Processed',
  model: 'gemini-3.8-flash',
  batchSize: 10,
  interviewSlot: 'Wednesday at 10:00 AM in the project time zone',
};

What does this code do?

  • CONFIG gives every shared router setting one source of truth.
  • intakeLabel identifies the label attached to new synthetic applications.
  • processedLabel identifies threads that the completed router has already handled.
  • batchSize limits each search to 10 threads.
  • The remaining values reserve the completed router's outcome labels and shared settings.
  • Save Code.gs.
  • Confirm the editor shows all seven properties inside CONFIG.

Configuration missing a value?

Compare each property name with the code block above. Keep the commas after every property except the final one.

Help me check my CONFIG object for a missing property or punctuation problem.

  • Add the Gmail search helper below CONFIG by pasting this function:
function getPendingThreads() {
  const query =
      'label:' + CONFIG.intakeLabel +
      ' has:attachment filename:pdf -label:' + CONFIG.processedLabel;
  return GmailApp.search(query, 0, CONFIG.batchSize);
}

How does the intake query work?

  • label: restricts the search to threads carrying the intake label.
  • has:attachment requires an attachment.
  • filename:pdf narrows the results to PDF filenames.
  • -label: excludes threads carrying the processed label.
  • GmailApp.search(query, 0, CONFIG.batchSize) returns up to 10 matching threads from the start of the results.

Why the built-in Gmail service?

The Advanced Gmail service offers extra detail. The built-in service covers this prototype's search.

It also provides the attachment access needed by the router.

  • Save Code.gs.
  • Confirm getPendingThreads appears in the function selector above the editor.

Search function not listed?

Check that the closing brace for CONFIG remains above getPendingThreads. A missing brace can stop the editor from recognizing the function.

Help me find why getPendingThreads is missing from the function selector.

Identify the newest PDF attachment

A Gmail search returns whole threads. The router needs the specific message and PDF attachment that represent each application.

  • Add the attachment helper below getPendingThreads by pasting this function:
function getPdfApplication(thread) {
  const messages = thread.getMessages();

  for (let i = messages.length - 1; i >= 0; i--) {
    const attachments = messages[i].getAttachments({
      includeInlineImages: false,
      includeAttachments: true,
    });

    for (let j = 0; j < attachments.length; j++) {
      const name = attachments[j].getName() || '';
      const isPdf =
          attachments[j].getContentType() === 'application/pdf' ||
          /\.pdf$/i.test(name);

      if (isPdf) {
        return {
          message: messages[i],
          pdf: attachments[j],
        };
      }
    }
  }

  throw new Error('No PDF attachment was found in the thread.');
}

How does the attachment helper work?

  • getMessages() retrieves every message from one Gmail thread.
  • The outer loop starts with the newest message.
  • getAttachments() includes file attachments while excluding inline images.
  • isPdf accepts an attachment with the PDF content type or a .pdf filename.
  • The returned object keeps the matching message beside its pdf attachment.
  • Save Code.gs.
  • Confirm getPdfApplication appears in the function selector above the editor.

Attachment helper not listed?

Check the nested loops for matching braces. Confirm the regular expression still reads /\.pdf$/i.

Help me debug the getPdfApplication function.

Run the inbox preview

The final function connects the search helper to the attachment helper. It records a compact preview in the execution log.

  • Find the line that starts with function getPendingThreads() {.
  • Add the preview function directly above that line by pasting this code:
function previewInbox() {
  const threads = getPendingThreads();
  Logger.log('Found ' + threads.length + ' pending application thread(s).');

  for (let i = 0; i < threads.length; i++) {
    const application = getPdfApplication(threads[i]);
    Logger.log(
        application.message.getSubject() + ' | ' + application.pdf.getName(),
    );
  }
}

What does the preview function do?

  • getPendingThreads() supplies the matching Gmail threads.
  • The first log entry records the number of pending threads.
  • The loop passes each thread to getPdfApplication().
  • The second log entry places the email subject beside the PDF filename.
  • Save Code.gs.

✔️ Awesome, I've got everything!

Great. Double check that Code.gs is saved before running the preview.

ⓧ I'd like to double check the full code

Compare your complete Code.gs file with this version:

const CONFIG = {
  intakeLabel: 'CV-Router-Inbox',
  acceptedLabel: 'CV-Router-Accepted',
  rejectedLabel: 'CV-Router-Rejected',
  processedLabel: 'CV-Router-Processed',
  model: 'gemini-3.8-flash',
  batchSize: 10,
  interviewSlot: 'Wednesday at 10:00 AM in the project time zone',
};

function previewInbox() {
  const threads = getPendingThreads();
  Logger.log('Found ' + threads.length + ' pending application thread(s).');

  for (let i = 0; i < threads.length; i++) {
    const application = getPdfApplication(threads[i]);
    Logger.log(
        application.message.getSubject() + ' | ' + application.pdf.getName(),
    );
  }
}

function getPendingThreads() {
  const query =
      'label:' + CONFIG.intakeLabel +
      ' has:attachment filename:pdf -label:' + CONFIG.processedLabel;
  return GmailApp.search(query, 0, CONFIG.batchSize);
}

function getPdfApplication(thread) {
  const messages = thread.getMessages();

  for (let i = messages.length - 1; i >= 0; i--) {
    const attachments = messages[i].getAttachments({
      includeInlineImages: false,
      includeAttachments: true,
    });

    for (let j = 0; j < attachments.length; j++) {
      const name = attachments[j].getName() || '';
      const isPdf =
          attachments[j].getContentType() === 'application/pdf' ||
          /\.pdf$/i.test(name);

      if (isPdf) {
        return {
          message: messages[i],
          pdf: attachments[j],
        };
      }
    }
  }

  throw new Error('No PDF attachment was found in the thread.');
}
  • Select previewInbox from the function selector above the editor.

A Gmail permission screen can feel like a big step. This code uses the approved access to search the synthetic test threads you prepared.

Before you run the preview, how many matching applications do you expect it to find?

  • Click Run in the Apps Script toolbar.
  • Complete the browser prompts to authorize Gmail access if this is your first run.

You should see Found 2 pending application thread(s). in the run log. Two more lines should show your test subjects beside their PDF filenames.

Each result contains a subject and filename. No SQL evidence or project evidence appears, which is the intended limitation of this first router slice.

  • Switch back to Gmail from earlier.
  • Compare each logged subject with the two threads carrying CV-Router-Inbox.
  • Confirm each logged filename matches its thread's PDF attachment.
  • Confirm both threads still have no Accepted or Rejected router label.
  • Confirm neither thread contains a reply draft.

Missing an application from the log?

  • Confirm the missing Gmail thread still carries the CV-Router-Inbox label.
  • Confirm the thread contains an attachment whose filename ends with .pdf.
  • Confirm the intake query still excludes only CV-Router-Processed.

Help me debug why previewInbox does not find both synthetic PDF applications.

That is your first working router slice: Gmail intake is now searchable from code. Next, you will read a synthetic CV and extract the job evidence needed for routing.

Extract CV Evidence with Gemini

Your Gmail inbox preview already finds both synthetic PDF applications. Their subjects and filenames cannot prove that a CV contains the required job evidence.

The Gemini API will read each synthetic CV. Google Apps Script will validate the returned JSON before calculating a preview category.

In this step, get ready to:
  • Define strict rules for extracting job-related CV evidence.
  • Build a schema-constrained Gemini request with output validation.
  • Run the evidence preview without changing any Gmail threads.
Define the evidence rules

The extraction prompt defines what counts as SQL experience, spreadsheet experience, or a completed data project. It also excludes protected characteristics from the analysis.

Keep every application synthetic

Gemini API free-tier content may be used to improve Google products. Use only the synthetic email text and CVs you created for this training project.

A human reviewer must inspect the original CV before making or communicating any employment decision.

  • Switch back to the Code.gs file in your Apps Script project.
  • Add the evidence prompt at the bottom of Code.gs by pasting this function:
function buildPrompt(message) {
  return [
    'This is a synthetic training exercise for a Junior Data Analyst role.',
    'Extract job-related evidence only. Do not make the final routing decision.',
    'Set hasSql true only when SQL is explicitly named and used.',
    'Set hasSpreadsheets true only when Excel or Google Sheets is explicitly named and used.',
    'Set hasDataProject true only when a completed data-analysis project states an objective and a result.',
    'If evidence is vague, implied, or absent, set the corresponding value to false.',
    'Ignore names, photos, addresses, age, race, ethnicity, religion, disability, health, sex, gender, pregnancy, sexual orientation, family status, and citizenship.',
    'Email subject: ' + message.getSubject(),
    'Email body: ' + message.getPlainBody().slice(0, 5000),
  ].join('\n');
}

What does this prompt do?

  • The three evidence rules require explicit proof for hasSql, hasSpreadsheets, and hasDataProject.
  • The protected-characteristics instruction keeps the extraction focused on job-related evidence.
  • The final two lines include the email subject. They limit the email body to its first 5,000 characters.
  • The prompt tells Gemini to extract evidence only. JavaScript remains responsible for calculating the category.
  • Save Code.gs.
  • Select previewInbox from the function menu.
  • Click Run to confirm the existing inbox preview still works.

You should see Found 2 pending application thread(s). followed by both synthetic email subjects and PDF filenames.

Inbox preview stopped working?

Check that buildPrompt sits outside getPdfApplication. A missing closing brace above the new function can prevent the file from running.

Compare the prompt function with the code block above if Apps Script highlights a syntax problem.

Help me find the syntax problem in my buildPrompt function.

Good progress. Your inbox scanner still works with the evidence rules in place.

Build the structured analysis

A schema tells Gemini which fields and data types the response must contain. Local validation provides a second check before the result reaches the category condition.

Why keep routing in JavaScript?

Gemini extracts constrained evidence from the document. The explicit JavaScript condition turns those facts into a category that you can inspect.

This separation keeps the final branch deterministic. You can trace every category back to three Boolean values.

The analysis function is long because it builds the document request, checks the API response, and parses the structured result. The next five code blocks are consecutive segments of that single function.

  • Insert analyzeApplication immediately above buildPrompt by starting with this segment:
function analyzeApplication(message, pdf) {
  const apiKey = PropertiesService.getScriptProperties()
      .getProperty('GEMINI_API_KEY');

  if (!apiKey) {
    throw new Error('The GEMINI_API_KEY script property is missing.');
  }

  const endpoint =
      'https://generativelanguage.googleapis.com/v1beta/models/' +
      CONFIG.model + ':generateContent';

How does the request start?

  • PropertiesService reads GEMINI_API_KEY from the script properties you configured earlier.
  • The missing-key check stops the request before it reaches Gemini.
  • The endpoint uses the model stored in CONFIG.model.
  • Continue directly below the endpoint with the document contents segment:
  const payload = {
    contents: [{
      parts: [
        {
          inline_data: {
            mime_type: 'application/pdf',
            data: Utilities.base64Encode(pdf.getBytes()),
          },
        },
        {
          text: buildPrompt(message),
        },
      ],
    }],

How does Gemini receive the application?

  • The first part converts the PDF bytes into Base64 data for the request.
  • The MIME type identifies those bytes as an application/pdf document.
  • The second part supplies the evidence rules with the synthetic email context.
  • Continue the same payload object with the response schema:
    generationConfig: {
      responseFormat: {
        text: {
          mimeType: 'APPLICATION_JSON',
          schema: {
            type: 'object',
            properties: {
              hasSql: {type: 'boolean'},
              hasSpreadsheets: {type: 'boolean'},
              hasDataProject: {type: 'boolean'},
              evidence: {type: 'array', items: {type: 'string'}},
              missingEvidence: {type: 'array', items: {type: 'string'}},
            },
            required: [
              'hasSql',
              'hasSpreadsheets',
              'hasDataProject',
              'evidence',
              'missingEvidence',
            ],
            additionalProperties: false,
          },
        },
      },
    },
  };

What does the schema enforce?

  • The response format requests a JSON object.
  • The three requirement fields must contain Boolean values.
  • The evidence fields must contain arrays of strings.
  • The required list prevents any of the five fields from being omitted.
  • Continue below payload with the API request and status check:
  const response = UrlFetchApp.fetch(endpoint, {
    method: 'post',
    contentType: 'application/json',
    headers: {'x-goog-api-key': apiKey},
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  });

  const responseText = response.getContentText();
  if (response.getResponseCode() !== 200) {
    throw new Error('Gemini API error: ' + responseText);
  }

How is the request checked?

  • UrlFetchApp sends the JSON payload to the Gemini endpoint.
  • The API key stays in the request header. It never enters Code.gs as a literal value.
  • The status check stops non-successful responses from reaching the JSON parser.
  • Finish analyzeApplication with the response parsing segment:
  const responseJson = JSON.parse(responseText);
  if (!responseJson.candidates || !responseJson.candidates[0] ||
      !responseJson.candidates[0].content ||
      !responseJson.candidates[0].content.parts ||
      !responseJson.candidates[0].content.parts[0] ||
      !responseJson.candidates[0].content.parts[0].text) {
    throw new Error('Gemini returned no structured text result.');
  }

  const analysis = JSON.parse(
      responseJson.candidates[0].content.parts[0].text,
  );
  validateAnalysis(analysis);
  return analysis;
}

How does the function finish?

  • The candidate check confirms that Gemini returned a text result in the expected response path.
  • The second JSON parse turns that generated text into the analysis object.
  • The function validates the object before returning it to the preview.
  • Add validateAnalysis directly below buildPrompt by pasting this function:
function validateAnalysis(analysis) {
  const booleanFields = ['hasSql', 'hasSpreadsheets', 'hasDataProject'];

  for (let i = 0; i < booleanFields.length; i++) {
    if (typeof analysis[booleanFields[i]] !== 'boolean') {
      throw new Error('Gemini returned an invalid Boolean field.');
    }
  }

  if (!Array.isArray(analysis.evidence) ||
      !Array.isArray(analysis.missingEvidence)) {
    throw new Error('Gemini returned an invalid evidence list.');
  }
}

Why validate structured output?

  • The loop verifies that each requirement field contains a real Boolean value.
  • The final condition verifies that both evidence fields contain arrays.
  • Invalid data stops here before it can affect the category condition.
  • Add getCategory directly below validateAnalysis by pasting this function:
function getCategory(analysis) {
  if (
    analysis.hasSql &&
    analysis.hasSpreadsheets &&
    analysis.hasDataProject
  ) {
    return 'accepted';
  }

  return 'rejected';
}

How is the preview category calculated?

The && operators require all three evidence fields to be true. Every other combination returns rejected.

This function only returns a string during this step. It does not apply a Gmail label or make an employment decision.

  • Insert previewClassification directly below previewInbox by pasting this function:
function previewClassification() {
  const threads = getPendingThreads();
  if (threads.length === 0) {
    throw new Error('No pending PDF applications were found.');
  }

  const application = getPdfApplication(threads[0]);
  const analysis = analyzeApplication(application.message, application.pdf);
  const result = {
    category: getCategory(analysis),
    analysis: analysis,
  };
  Logger.log(JSON.stringify(result, null, 2));
}

What does the preview run?

  • The function selects the first pending synthetic application.
  • It sends that application to analyzeApplication for evidence extraction.
  • It calculates a category from the validated Booleans.
  • It writes formatted JSON to the run log without changing the Gmail thread.
  • Save Code.gs.

✔️ Awesome, I've got everything!

Your evidence extraction functions are in place. Double check that you saved Code.gs before running the preview.

ⓧ I'd like to double check the full code

Compare your complete Code.gs file with this version:

const CONFIG = {
  intakeLabel: 'CV-Router-Inbox',
  acceptedLabel: 'CV-Router-Accepted',
  rejectedLabel: 'CV-Router-Rejected',
  processedLabel: 'CV-Router-Processed',
  model: 'gemini-3.8-flash',
  batchSize: 10,
  interviewSlot: 'Wednesday at 10:00 AM in the project time zone',
};

function previewInbox() {
  const threads = getPendingThreads();
  Logger.log('Found ' + threads.length + ' pending application thread(s).');

  for (let i = 0; i < threads.length; i++) {
    const application = getPdfApplication(threads[i]);
    Logger.log(
        application.message.getSubject() + ' | ' + application.pdf.getName(),
    );
  }
}

function previewClassification() {
  const threads = getPendingThreads();
  if (threads.length === 0) {
    throw new Error('No pending PDF applications were found.');
  }

  const application = getPdfApplication(threads[0]);
  const analysis = analyzeApplication(application.message, application.pdf);
  const result = {
    category: getCategory(analysis),
    analysis: analysis,
  };
  Logger.log(JSON.stringify(result, null, 2));
}

function getPendingThreads() {
  const query =
      'label:' + CONFIG.intakeLabel +
      ' has:attachment filename:pdf -label:' + CONFIG.processedLabel;
  return GmailApp.search(query, 0, CONFIG.batchSize);
}

function getPdfApplication(thread) {
  const messages = thread.getMessages();

  for (let i = messages.length - 1; i >= 0; i--) {
    const attachments = messages[i].getAttachments({
      includeInlineImages: false,
      includeAttachments: true,
    });

    for (let j = 0; j < attachments.length; j++) {
      const name = attachments[j].getName() || '';
      const isPdf =
          attachments[j].getContentType() === 'application/pdf' ||
          /\.pdf$/i.test(name);

      if (isPdf) {
        return {
          message: messages[i],
          pdf: attachments[j],
        };
      }
    }
  }

  throw new Error('No PDF attachment was found in the thread.');
}

function analyzeApplication(message, pdf) {
  const apiKey = PropertiesService.getScriptProperties()
      .getProperty('GEMINI_API_KEY');

  if (!apiKey) {
    throw new Error('The GEMINI_API_KEY script property is missing.');
  }

  const endpoint =
      'https://generativelanguage.googleapis.com/v1beta/models/' +
      CONFIG.model + ':generateContent';

  const payload = {
    contents: [{
      parts: [
        {
          inline_data: {
            mime_type: 'application/pdf',
            data: Utilities.base64Encode(pdf.getBytes()),
          },
        },
        {
          text: buildPrompt(message),
        },
      ],
    }],
    generationConfig: {
      responseFormat: {
        text: {
          mimeType: 'APPLICATION_JSON',
          schema: {
            type: 'object',
            properties: {
              hasSql: {type: 'boolean'},
              hasSpreadsheets: {type: 'boolean'},
              hasDataProject: {type: 'boolean'},
              evidence: {type: 'array', items: {type: 'string'}},
              missingEvidence: {type: 'array', items: {type: 'string'}},
            },
            required: [
              'hasSql',
              'hasSpreadsheets',
              'hasDataProject',
              'evidence',
              'missingEvidence',
            ],
            additionalProperties: false,
          },
        },
      },
    },
  };

  const response = UrlFetchApp.fetch(endpoint, {
    method: 'post',
    contentType: 'application/json',
    headers: {'x-goog-api-key': apiKey},
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  });

  const responseText = response.getContentText();
  if (response.getResponseCode() !== 200) {
    throw new Error('Gemini API error: ' + responseText);
  }

  const responseJson = JSON.parse(responseText);
  if (!responseJson.candidates || !responseJson.candidates[0] ||
      !responseJson.candidates[0].content ||
      !responseJson.candidates[0].content.parts ||
      !responseJson.candidates[0].content.parts[0] ||
      !responseJson.candidates[0].content.parts[0].text) {
    throw new Error('Gemini returned no structured text result.');
  }

  const analysis = JSON.parse(
      responseJson.candidates[0].content.parts[0].text,
  );
  validateAnalysis(analysis);
  return analysis;
}

function buildPrompt(message) {
  return [
    'This is a synthetic training exercise for a Junior Data Analyst role.',
    'Extract job-related evidence only. Do not make the final routing decision.',
    'Set hasSql true only when SQL is explicitly named and used.',
    'Set hasSpreadsheets true only when Excel or Google Sheets is explicitly named and used.',
    'Set hasDataProject true only when a completed data-analysis project states an objective and a result.',
    'If evidence is vague, implied, or absent, set the corresponding value to false.',
    'Ignore names, photos, addresses, age, race, ethnicity, religion, disability, health, sex, gender, pregnancy, sexual orientation, family status, and citizenship.',
    'Email subject: ' + message.getSubject(),
    'Email body: ' + message.getPlainBody().slice(0, 5000),
  ].join('\n');
}

function validateAnalysis(analysis) {
  const booleanFields = ['hasSql', 'hasSpreadsheets', 'hasDataProject'];

  for (let i = 0; i < booleanFields.length; i++) {
    if (typeof analysis[booleanFields[i]] !== 'boolean') {
      throw new Error('Gemini returned an invalid Boolean field.');
    }
  }

  if (!Array.isArray(analysis.evidence) ||
      !Array.isArray(analysis.missingEvidence)) {
    throw new Error('Gemini returned an invalid evidence list.');
  }
}

function getCategory(analysis) {
  if (
    analysis.hasSql &&
    analysis.hasSpreadsheets &&
    analysis.hasDataProject
  ) {
    return 'accepted';
  }

  return 'rejected';
}
Run the evidence preview

The first external request may start an authorization flow for the additional Apps Script services. The script reads the stored key without displaying its value.

Before you run the preview, which evidence fields do you expect to be true for the first pending synthetic CV?

  • Select previewClassification from the function menu.
  • Click Run.
  • Follow the authorization prompts if Apps Script requests additional access.
  • Return to the run log after the execution finishes.

You should see formatted JSON containing hasSql, hasSpreadsheets, and hasDataProject Boolean values.

You should also see evidence and missingEvidence arrays. The category value should match the three extracted requirements.

What does this result prove?

The router can now read a synthetic PDF and return constrained job-evidence fields. The local checks confirm that the fields have usable data types.

The run only logs a preview category. Your Gmail threads remain unprocessed with no outcome labels or reply drafts.

Classification preview failed?

  • If the script property is missing, return to Project Settings and confirm that its name is exactly GEMINI_API_KEY.
  • If the API request fails, confirm that the key stored in the script property is the active key from your AI Studio project.
  • If parsing or validation fails, compare the payload schema and response parsing with the full-code tab above.

Help me debug my structured Gemini response.

That is the evidence layer working. Your script can now turn a synthetic CV into validated fields that an explicit condition can inspect.

Your router now understands the required evidence without changing the inbox. Next, you will turn that analysis into visible Gmail labels and human-reviewed reply drafts.

Route Applications and Create Drafts

Your Google Apps Script can now turn each synthetic CV into validated evidence. Gemini supplies the facts while your existing condition determines the category.

Those facts still need to organize your Gmail inbox. This step uses the Boolean condition to apply one outcome label and create one unsent draft for human review.

In this step, get ready to:
  • Create the Accepted, Rejected, and Processed routing labels.
  • Build editable draft replies with recruiter-only evidence summaries.
  • Route both synthetic applications and verify that no email is sent.
Create the routing labels

Outcome labels make each routing decision visible inside Gmail. A shared helper also ensures that rerunning the router reuses the same labels.

  • Switch back to Code.gs in the Apps Script editor.
  • Place your cursor after the closing brace of getCategory.
  • Add the label helpers by pasting this code:
function getOrCreateLabels() {
  return {
    accepted: getOrCreateLabel(CONFIG.acceptedLabel),
    rejected: getOrCreateLabel(CONFIG.rejectedLabel),
    processed: getOrCreateLabel(CONFIG.processedLabel),
  };
}

function getOrCreateLabel(name) {
  return GmailApp.getUserLabelByName(name) || GmailApp.createLabel(name);
}

What do these helpers do?

  • The getOrCreateLabels function builds a lookup object for the three routing outcomes.
  • The getOrCreateLabel function retrieves an existing label or creates it when it is missing.
  • The keys accepted and rejected match the values returned by getCategory.

The first Gmail service run may request access to your mailbox. The script needs that access to create the internal routing labels.

  • Save Code.gs using the save control in the Apps Script toolbar.
  • Select getOrCreateLabels from the function menu in the Apps Script toolbar.
  • Click Run.
  • Complete the browser permission flow if Apps Script requests access.
  • Return to Gmail after the execution finishes.
  • Confirm that CV-Router-Accepted, CV-Router-Rejected, and CV-Router-Processed appear in the label list.

Good progress. Your inbox now has a visible destination for every routing result.

Labels missing from Gmail?

Refresh Gmail after the Apps Script execution finishes. If the labels remain missing, confirm that the execution completed under the same Google account as your test inbox.

Check that getOrCreateLabels contains all three values from CONFIG.

Help me troubleshoot missing Gmail labels.

Build human-reviewed draft replies

Each draft starts with an internal review block that exposes the route and supporting evidence. The applicant-facing text stays editable so a person can inspect the PDF before communicating any decision.

  • Find the getOrCreateLabels function near the bottom of Code.gs.
  • Place your cursor on the blank line immediately above getOrCreateLabels.
  • Start the draft builder by pasting this first part:
function buildDraft(category, analysis) {
  const reviewBlock = [
    '[RECRUITER REVIEW - REMOVE BEFORE SENDING]',
    'Internal route: ' + category.toUpperCase(),
    'Evidence extracted:',
    bullets(analysis.evidence, 'No qualifying evidence found.'),
    'Missing required evidence:',
    bullets(analysis.missingEvidence, 'None reported.'),
    '[END REVIEW BLOCK]',
    '',
  ].join('\n');

  if (category === 'accepted') {
    return reviewBlock + [
      'Hello,',
      '',
      'Thank you for applying for the Junior Data Analyst role.',
      '',
      'We would like to invite you to a 30-minute interview. ' +
          'Our proposed slot is ' + CONFIG.interviewSlot + '.',
      'Please reply to confirm the slot or suggest an alternative.',
      '',
      'Kind regards,',
      'Recruitment Team',
    ].join('\n');
  }

How does the Accepted draft work?

  • The reviewBlock records the internal route and both evidence lists above the applicant-facing reply.
  • The Accepted branch reads the proposed interview time from CONFIG.interviewSlot.
  • The review markers tell the recruiter which internal text must be removed before any manual send.
  • Complete buildDraft and add the list formatter by pasting this block directly below the first part:
  return reviewBlock + [
    'Hello,',
    '',
    'Thank you for applying for the Junior Data Analyst role.',
    '',
    'After reviewing the role requirements, we will not be progressing ' +
        'your application to interview at this time.',
    'For future applications, consider making the following evidence explicit:',
    bullets(analysis.missingEvidence, 'Add clearer evidence against the required skills.'),
    '',
    'Kind regards,',
    'Recruitment Team',
  ].join('\n');
}

function bullets(items, fallback) {
  if (!items || items.length === 0) {
    return '- ' + fallback;
  }

  const lines = [];
  for (let i = 0; i < items.length; i++) {
    lines.push('- ' + items[i]);
  }
  return lines.join('\n');
}

How does the Rejected draft work?

  • The Rejected branch turns analysis.missingEvidence into evidence-based feedback.
  • The bullets helper formats each evidence item on its own line.
  • The fallback text keeps the draft readable when an evidence list is empty.
  • Save Code.gs using the save control in the Apps Script toolbar.
  • Open the function menu in the Apps Script toolbar.
  • Confirm that buildDraft and bullets appear in the function list.

The function list confirms that Apps Script can parse both completed functions.

Functions missing from the menu?

A missing function usually means that one of the pasted blocks has an unmatched brace. Compare the end of buildDraft with the start of bullets.

Help me repair the draft functions.

Route and verify the applications

The router now needs to connect the pending thread search with evidence extraction and draft creation. It marks a thread as processed only after the outcome label and draft have been created.

  • Find the closing brace of previewClassification near the top of Code.gs.
  • Place your cursor on the blank line immediately below previewClassification.
  • Start the routing function by pasting this first part:
function routeApplications() {
  const threads = getPendingThreads();
  const labels = getOrCreateLabels();

  if (threads.length === 0) {
    Logger.log('No pending PDF applications were found.');
    return;
  }

  for (let i = 0; i < threads.length; i++) {
    const thread = threads[i];

    try {
      const application = getPdfApplication(thread);
      const analysis = analyzeApplication(application.message, application.pdf);
      const category = getCategory(analysis);

      if (category === 'accepted') {
        thread.removeLabel(labels.rejected);
      } else {
        thread.removeLabel(labels.accepted);
      }

How does routing stay exclusive?

  • The router retrieves only threads that do not have the Processed label.
  • The existing getCategory condition requires SQL, spreadsheet, and completed-project evidence for the Accepted route.
  • Removing the opposite outcome label keeps each thread in exactly one category.
  • Complete routeApplications by pasting this block directly below the first part:
      thread.addLabel(labels[category]);
      application.message.createDraftReply(buildDraft(category, analysis));
      thread.addLabel(labels.processed);

      Logger.log(
          application.message.getSubject() + ' routed to ' + category + '.',
      );
    } catch (error) {
      Logger.log(
          'Routing failed for thread ' + thread.getId() + ': ' + error.message,
      );
    }
  }
}

What completes each route?

  • The outcome label comes from labels[category].
  • The createDraftReply call creates an editable reply draft without sending it.
  • The Processed label excludes the completed thread from later pending searches.
  • The local error handler logs a failed thread while allowing the loop to continue.
  • Save Code.gs using the save control in the Apps Script toolbar.

✔️ Awesome, I've got everything!

Your routing helpers and draft builder are saved. The next run connects them to the two pending synthetic applications.

ⓧ I'd like to double check the full code

This is the complete cumulative Code.gs file for this step:

const CONFIG = {
  intakeLabel: 'CV-Router-Inbox',
  acceptedLabel: 'CV-Router-Accepted',
  rejectedLabel: 'CV-Router-Rejected',
  processedLabel: 'CV-Router-Processed',
  model: 'gemini-3.8-flash',
  batchSize: 10,
  interviewSlot: 'Wednesday at 10:00 AM in the project time zone',
};

function previewInbox() {
  const threads = getPendingThreads();
  Logger.log('Found ' + threads.length + ' pending application thread(s).');

  for (let i = 0; i < threads.length; i++) {
    const application = getPdfApplication(threads[i]);
    Logger.log(
        application.message.getSubject() + ' | ' + application.pdf.getName(),
    );
  }
}

function previewClassification() {
  const threads = getPendingThreads();
  if (threads.length === 0) {
    throw new Error('No pending PDF applications were found.');
  }

  const application = getPdfApplication(threads[0]);
  const analysis = analyzeApplication(application.message, application.pdf);
  const result = {
    category: getCategory(analysis),
    analysis: analysis,
  };
  Logger.log(JSON.stringify(result, null, 2));
}

function routeApplications() {
  const threads = getPendingThreads();
  const labels = getOrCreateLabels();

  if (threads.length === 0) {
    Logger.log('No pending PDF applications were found.');
    return;
  }

  for (let i = 0; i < threads.length; i++) {
    const thread = threads[i];

    try {
      const application = getPdfApplication(thread);
      const analysis = analyzeApplication(application.message, application.pdf);
      const category = getCategory(analysis);

      if (category === 'accepted') {
        thread.removeLabel(labels.rejected);
      } else {
        thread.removeLabel(labels.accepted);
      }

      thread.addLabel(labels[category]);
      application.message.createDraftReply(buildDraft(category, analysis));
      thread.addLabel(labels.processed);

      Logger.log(
          application.message.getSubject() + ' routed to ' + category + '.',
      );
    } catch (error) {
      Logger.log(
          'Routing failed for thread ' + thread.getId() + ': ' + error.message,
      );
    }
  }
}

function getPendingThreads() {
  const query =
      'label:' + CONFIG.intakeLabel +
      ' has:attachment filename:pdf -label:' + CONFIG.processedLabel;
  return GmailApp.search(query, 0, CONFIG.batchSize);
}

function getPdfApplication(thread) {
  const messages = thread.getMessages();

  for (let i = messages.length - 1; i >= 0; i--) {
    const attachments = messages[i].getAttachments({
      includeInlineImages: false,
      includeAttachments: true,
    });

    for (let j = 0; j < attachments.length; j++) {
      const name = attachments[j].getName() || '';
      const isPdf =
          attachments[j].getContentType() === 'application/pdf' ||
          /\.pdf$/i.test(name);

      if (isPdf) {
        return {
          message: messages[i],
          pdf: attachments[j],
        };
      }
    }
  }

  throw new Error('No PDF attachment was found in the thread.');
}

function analyzeApplication(message, pdf) {
  const apiKey = PropertiesService.getScriptProperties()
      .getProperty('GEMINI_API_KEY');

  if (!apiKey) {
    throw new Error('The GEMINI_API_KEY script property is missing.');
  }

  const endpoint =
      'https://generativelanguage.googleapis.com/v1beta/models/' +
      CONFIG.model + ':generateContent';

  const payload = {
    contents: [{
      parts: [
        {
          inline_data: {
            mime_type: 'application/pdf',
            data: Utilities.base64Encode(pdf.getBytes()),
          },
        },
        {
          text: buildPrompt(message),
        },
      ],
    }],
    generationConfig: {
      responseFormat: {
        text: {
          mimeType: 'APPLICATION_JSON',
          schema: {
            type: 'object',
            properties: {
              hasSql: {type: 'boolean'},
              hasSpreadsheets: {type: 'boolean'},
              hasDataProject: {type: 'boolean'},
              evidence: {type: 'array', items: {type: 'string'}},
              missingEvidence: {type: 'array', items: {type: 'string'}},
            },
            required: [
              'hasSql',
              'hasSpreadsheets',
              'hasDataProject',
              'evidence',
              'missingEvidence',
            ],
            additionalProperties: false,
          },
        },
      },
    },
  };

  const response = UrlFetchApp.fetch(endpoint, {
    method: 'post',
    contentType: 'application/json',
    headers: {'x-goog-api-key': apiKey},
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  });

  const responseText = response.getContentText();
  if (response.getResponseCode() !== 200) {
    throw new Error('Gemini API error: ' + responseText);
  }

  const responseJson = JSON.parse(responseText);
  if (!responseJson.candidates || !responseJson.candidates[0] ||
      !responseJson.candidates[0].content ||
      !responseJson.candidates[0].content.parts ||
      !responseJson.candidates[0].content.parts[0] ||
      !responseJson.candidates[0].content.parts[0].text) {
    throw new Error('Gemini returned no structured text result.');
  }

  const analysis = JSON.parse(
      responseJson.candidates[0].content.parts[0].text,
  );
  validateAnalysis(analysis);
  return analysis;
}

function buildPrompt(message) {
  return [
    'This is a synthetic training exercise for a Junior Data Analyst role.',
    'Extract job-related evidence only. Do not make the final routing decision.',
    'Set hasSql true only when SQL is explicitly named and used.',
    'Set hasSpreadsheets true only when Excel or Google Sheets is explicitly named and used.',
    'Set hasDataProject true only when a completed data-analysis project states an objective and a result.',
    'If evidence is vague, implied, or absent, set the corresponding value to false.',
    'Ignore names, photos, addresses, age, race, ethnicity, religion, disability, health, sex, gender, pregnancy, sexual orientation, family status, and citizenship.',
    'Email subject: ' + message.getSubject(),
    'Email body: ' + message.getPlainBody().slice(0, 5000),
  ].join('\n');
}

function validateAnalysis(analysis) {
  const booleanFields = ['hasSql', 'hasSpreadsheets', 'hasDataProject'];

  for (let i = 0; i < booleanFields.length; i++) {
    if (typeof analysis[booleanFields[i]] !== 'boolean') {
      throw new Error('Gemini returned an invalid Boolean field.');
    }
  }

  if (!Array.isArray(analysis.evidence) ||
      !Array.isArray(analysis.missingEvidence)) {
    throw new Error('Gemini returned an invalid evidence list.');
  }
}

function getCategory(analysis) {
  if (
    analysis.hasSql &&
    analysis.hasSpreadsheets &&
    analysis.hasDataProject
  ) {
    return 'accepted';
  }

  return 'rejected';
}

function buildDraft(category, analysis) {
  const reviewBlock = [
    '[RECRUITER REVIEW - REMOVE BEFORE SENDING]',
    'Internal route: ' + category.toUpperCase(),
    'Evidence extracted:',
    bullets(analysis.evidence, 'No qualifying evidence found.'),
    'Missing required evidence:',
    bullets(analysis.missingEvidence, 'None reported.'),
    '[END REVIEW BLOCK]',
    '',
  ].join('\n');

  if (category === 'accepted') {
    return reviewBlock + [
      'Hello,',
      '',
      'Thank you for applying for the Junior Data Analyst role.',
      '',
      'We would like to invite you to a 30-minute interview. ' +
          'Our proposed slot is ' + CONFIG.interviewSlot + '.',
      'Please reply to confirm the slot or suggest an alternative.',
      '',
      'Kind regards,',
      'Recruitment Team',
    ].join('\n');
  }

  return reviewBlock + [
    'Hello,',
    '',
    'Thank you for applying for the Junior Data Analyst role.',
    '',
    'After reviewing the role requirements, we will not be progressing ' +
        'your application to interview at this time.',
    'For future applications, consider making the following evidence explicit:',
    bullets(analysis.missingEvidence, 'Add clearer evidence against the required skills.'),
    '',
    'Kind regards,',
    'Recruitment Team',
  ].join('\n');
}

function bullets(items, fallback) {
  if (!items || items.length === 0) {
    return '- ' + fallback;
  }

  const lines = [];
  for (let i = 0; i < items.length; i++) {
    lines.push('- ' + items[i]);
  }
  return lines.join('\n');
}

function getOrCreateLabels() {
  return {
    accepted: getOrCreateLabel(CONFIG.acceptedLabel),
    rejected: getOrCreateLabel(CONFIG.rejectedLabel),
    processed: getOrCreateLabel(CONFIG.processedLabel),
  };
}

function getOrCreateLabel(name) {
  return GmailApp.getUserLabelByName(name) || GmailApp.createLabel(name);
}

How to use this reference

This reference preserves the earlier evidence-extraction code. It adds only the routing, draft, bullet-formatting, and label functions required in this step.

This run can request access for Gmail drafts and external requests. Review the request under the same Google account that owns your synthetic inbox.

  • Select routeApplications from the function menu in the Apps Script toolbar.

Before you click Run, which synthetic CV do you expect to reach each outcome label?

  • Click Run.
  • Complete the browser permission flow if Apps Script requests additional access.
  • Wait for the execution to finish in the Apps Script execution panel.

You should see one routing log entry for each synthetic application. One entry ends with accepted while the other ends with rejected.

That is the full workflow working. Both PDFs have now produced visible routes and editable responses.

  • Switch back to Gmail.
  • Refresh the inbox.
  • Open the qualifying synthetic CV A thread.
  • Confirm that the thread has CV-Router-Accepted and CV-Router-Processed.
  • Open the reply draft inside the CV A thread.
  • Confirm that the draft contains the recruiter review block.
  • Confirm that the invitation proposes Wednesday at 10:00 AM in the project time zone.

CV A retains the CV-Router-Inbox label. Its draft remains unsent and editable.

  • Open the weaker synthetic CV B thread.
  • Confirm that the thread has CV-Router-Rejected and CV-Router-Processed.
  • Open the reply draft inside the CV B thread.
  • Confirm that the draft contains the recruiter review block.
  • Confirm that the applicant-facing response contains missing-evidence feedback.

CV B also retains the CV-Router-Inbox label. Exactly one outcome label appears on each routed thread.

Why do the drafts stay unsent?

The router creates internal categories and editable reply drafts. No send operation exists in Code.gs.

A human reviewer must inspect the synthetic PDF, extracted evidence, route, and reply before removing the review block. The reviewer controls every outgoing message.

  • Return to the Apps Script editor.
  • Select routeApplications from the function menu.

Before you rerun the router, do you expect it to create another draft for either processed thread?

  • Click Run.
  • Wait for the execution to finish.

The run log should show No pending PDF applications were found. Each thread still has exactly one unsent reply draft.

Routes or drafts look wrong?

  • If both threads have the same outcome, compare their extracted Boolean values with the unchanged getCategory condition.
  • If a draft is missing, confirm that createDraftReply appears before the Processed label is applied.
  • If a thread routes twice, confirm that getPendingThreads excludes CONFIG.processedLabel.

Help me debug my application routes and drafts.

Your synthetic applications now move through two visible routes without sending a message. Next, you will install the recurring trigger that runs this workflow automatically.

Automate the Router

Your manual Gmail router already uses Gemini to classify synthetic applications. It creates editable drafts for human review.

The remaining chore is launching the router yourself whenever a new application arrives.

A Google Apps Script time-driven trigger can check the controlled intake every 10 minutes. The router still leaves every outgoing message unsent.

In this step, get ready to:
  • Add controls that install or remove the router trigger.
  • Install exactly one recurring 10-minute trigger.
  • Route a third synthetic application without another manual run.
Add the trigger controls

Trigger setup needs an installation control plus a matching removal control. The removal control lets the installer clear old copies before creating a new schedule.

  • Return to the Apps Script editor from earlier.
  • In Code.gs, place your cursor after the closing brace of getOrCreateLabel().
  • Add the pause and cleanup control by pasting this code:
function removeRouterTriggers() {
  const triggers = ScriptApp.getProjectTriggers();
  let removed = 0;
  for (let i = 0; i < triggers.length; i++) {
    if (triggers[i].getHandlerFunction() === 'routeApplications') {
      ScriptApp.deleteTrigger(triggers[i]);
      removed++;
    }
  }
  Logger.log('Removed ' + removed + ' routeApplications trigger(s).');
}

What does this code do?

  • The getProjectTriggers() call retrieves the triggers owned by your account for this project.
  • The getHandlerFunction() check limits cleanup to triggers that call routeApplications.
  • The deleteTrigger() call removes each matching trigger.
  • The final log records how many matching triggers were removed.
  • Save Code.gs by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Open the function selector in the Apps Script toolbar to confirm removeRouterTriggers is listed.

You should see removeRouterTriggers alongside the router functions from earlier.

Missing the removal function?

Confirm that the new function sits outside getOrCreateLabel(). A missing closing brace can hide the function from the selector.

Use the editor's syntax warning to locate an unmatched brace before saving again.

Help me find why removeRouterTriggers is missing from the Apps Script function selector.

  • Place your cursor immediately above function removeRouterTriggers() {.
  • Add the trigger installer by pasting this code:
function installRouterTrigger() {
  removeRouterTriggers();
  ScriptApp.newTrigger('routeApplications').timeBased().everyMinutes(10).create();
  Logger.log('A 10-minute routeApplications trigger was installed.');
}

How does the installer avoid duplicates?

  • The installer calls removeRouterTriggers() before creating a schedule.
  • The newTrigger('routeApplications') call connects the schedule to the existing router.
  • The timeBased() call creates a clock-based trigger.
  • The everyMinutes(10) call sets the intake interval to 10 minutes.
  • Save Code.gs by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Open the function selector in the Apps Script toolbar to confirm installRouterTrigger is listed.

You should now see both trigger controls in the function selector.

Missing the installer?

Check that installRouterTrigger() has its own closing brace. Confirm that it appears above removeRouterTriggers().

Help me fix an Apps Script function that is missing from the selector.

✔️ Awesome, I've got everything!

Both trigger controls are saved in Code.gs.

ⓧ I'd like to double check the full code

The complete Code.gs file is shown below for comparison.

const CONFIG = {
  intakeLabel: 'CV-Router-Inbox',
  acceptedLabel: 'CV-Router-Accepted',
  rejectedLabel: 'CV-Router-Rejected',
  processedLabel: 'CV-Router-Processed',
  model: 'gemini-3.8-flash',
  batchSize: 10,
  interviewSlot: 'Wednesday at 10:00 AM in the project time zone',
};

function previewInbox() {
  const threads = getPendingThreads();
  Logger.log('Found ' + threads.length + ' pending application thread(s).');

  for (let i = 0; i < threads.length; i++) {
    const application = getPdfApplication(threads[i]);
    Logger.log(
        application.message.getSubject() + ' | ' + application.pdf.getName(),
    );
  }
}

function previewClassification() {
  const threads = getPendingThreads();
  if (threads.length === 0) {
    throw new Error('No pending PDF applications were found.');
  }

  const application = getPdfApplication(threads[0]);
  const analysis = analyzeApplication(application.message, application.pdf);
  const result = { category: getCategory(analysis), analysis: analysis };
  Logger.log(JSON.stringify(result, null, 2));
}

function routeApplications() {
  const threads = getPendingThreads();
  const labels = getOrCreateLabels();
  if (threads.length === 0) {
    Logger.log('No pending PDF applications were found.');
    return;
  }
  for (let i = 0; i < threads.length; i++) {
    const thread = threads[i];
    try {
      const application = getPdfApplication(thread);
      const analysis = analyzeApplication(application.message, application.pdf);
      const category = getCategory(analysis);
      if (category === 'accepted') thread.removeLabel(labels.rejected);
      else thread.removeLabel(labels.accepted);
      thread.addLabel(labels[category]);
      application.message.createDraftReply(buildDraft(category, analysis));
      thread.addLabel(labels.processed);
      Logger.log(application.message.getSubject() + ' routed to ' + category + '.');
    } catch (error) {
      Logger.log('Routing failed for thread ' + thread.getId() + ': ' + error.message);
    }
  }
}

function getPendingThreads() {
  const query = 'label:' + CONFIG.intakeLabel + ' has:attachment filename:pdf -label:' + CONFIG.processedLabel;
  return GmailApp.search(query, 0, CONFIG.batchSize);
}

function getPdfApplication(thread) {
  const messages = thread.getMessages();
  for (let i = messages.length - 1; i >= 0; i--) {
    const attachments = messages[i].getAttachments({includeInlineImages: false, includeAttachments: true});
    for (let j = 0; j < attachments.length; j++) {
      const name = attachments[j].getName() || '';
      const isPdf = attachments[j].getContentType() === 'application/pdf' || /\.pdf$/i.test(name);
      if (isPdf) return {message: messages[i], pdf: attachments[j]};
    }
  }
  throw new Error('No PDF attachment was found in the thread.');
}

function analyzeApplication(message, pdf) {
  const apiKey = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
  if (!apiKey) throw new Error('The GEMINI_API_KEY script property is missing.');
  const endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' + CONFIG.model + ':generateContent';
  const payload = {contents: [{parts: [{inline_data: {mime_type: 'application/pdf', data: Utilities.base64Encode(pdf.getBytes())}}, {text: buildPrompt(message)}]}], generationConfig: {responseFormat: {text: {mimeType: 'APPLICATION_JSON', schema: {type: 'object', properties: {hasSql: {type: 'boolean'}, hasSpreadsheets: {type: 'boolean'}, hasDataProject: {type: 'boolean'}, evidence: {type: 'array', items: {type: 'string'}}, missingEvidence: {type: 'array', items: {type: 'string'}}}, required: ['hasSql', 'hasSpreadsheets', 'hasDataProject', 'evidence', 'missingEvidence'], additionalProperties: false}}}}};
  const response = UrlFetchApp.fetch(endpoint, {method: 'post', contentType: 'application/json', headers: {'x-goog-api-key': apiKey}, payload: JSON.stringify(payload), muteHttpExceptions: true});
  const responseText = response.getContentText();
  if (response.getResponseCode() !== 200) throw new Error('Gemini API error: ' + responseText);
  const responseJson = JSON.parse(responseText);
  if (!responseJson.candidates || !responseJson.candidates[0] || !responseJson.candidates[0].content || !responseJson.candidates[0].content.parts || !responseJson.candidates[0].content.parts[0] || !responseJson.candidates[0].content.parts[0].text) throw new Error('Gemini returned no structured text result.');
  const analysis = JSON.parse(responseJson.candidates[0].content.parts[0].text);
  validateAnalysis(analysis);
  return analysis;
}

function buildPrompt(message) {
  return ['This is a synthetic training exercise for a Junior Data Analyst role.', 'Extract job-related evidence only. Do not make the final routing decision.', 'Set hasSql true only when SQL is explicitly named and used.', 'Set hasSpreadsheets true only when Excel or Google Sheets is explicitly named and used.', 'Set hasDataProject true only when a completed data-analysis project states an objective and a result.', 'If evidence is vague, implied, or absent, set the corresponding value to false.', 'Ignore names, photos, addresses, age, race, ethnicity, religion, disability, health, sex, gender, pregnancy, sexual orientation, family status, and citizenship.', 'Email subject: ' + message.getSubject(), 'Email body: ' + message.getPlainBody().slice(0, 5000)].join('\n');
}

function validateAnalysis(analysis) {
  const booleanFields = ['hasSql', 'hasSpreadsheets', 'hasDataProject'];
  for (let i = 0; i < booleanFields.length; i++) if (typeof analysis[booleanFields[i]] !== 'boolean') throw new Error('Gemini returned an invalid Boolean field.');
  if (!Array.isArray(analysis.evidence) || !Array.isArray(analysis.missingEvidence)) throw new Error('Gemini returned an invalid evidence list.');
}

function getCategory(analysis) {
  if (analysis.hasSql && analysis.hasSpreadsheets && analysis.hasDataProject) return 'accepted';
  return 'rejected';
}

function buildDraft(category, analysis) {
  const reviewBlock = ['[RECRUITER REVIEW - REMOVE BEFORE SENDING]', 'Internal route: ' + category.toUpperCase(), 'Evidence extracted:', bullets(analysis.evidence, 'No qualifying evidence found.'), 'Missing required evidence:', bullets(analysis.missingEvidence, 'None reported.'), '[END REVIEW BLOCK]', ''].join('\n');
  if (category === 'accepted') return reviewBlock + ['Hello,', '', 'Thank you for applying for the Junior Data Analyst role.', '', 'We would like to invite you to a 30-minute interview. Our proposed slot is ' + CONFIG.interviewSlot + '.', 'Please reply to confirm the slot or suggest an alternative.', '', 'Kind regards,', 'Recruitment Team'].join('\n');
  return reviewBlock + ['Hello,', '', 'Thank you for applying for the Junior Data Analyst role.', '', 'After reviewing the role requirements, we will not be progressing your application to interview at this time.', 'For future applications, consider making the following evidence explicit:', bullets(analysis.missingEvidence, 'Add clearer evidence against the required skills.'), '', 'Kind regards,', 'Recruitment Team'].join('\n');
}

function bullets(items, fallback) {
  if (!items || items.length === 0) return '- ' + fallback;
  const lines = [];
  for (let i = 0; i < items.length; i++) lines.push('- ' + items[i]);
  return lines.join('\n');
}

function getOrCreateLabels() {
  return {accepted: getOrCreateLabel(CONFIG.acceptedLabel), rejected: getOrCreateLabel(CONFIG.rejectedLabel), processed: getOrCreateLabel(CONFIG.processedLabel)};
}

function getOrCreateLabel(name) {
  return GmailApp.getUserLabelByName(name) || GmailApp.createLabel(name);
}

function installRouterTrigger() {
  removeRouterTriggers();
  ScriptApp.newTrigger('routeApplications').timeBased().everyMinutes(10).create();
  Logger.log('A 10-minute routeApplications trigger was installed.');
}

function removeRouterTriggers() {
  const triggers = ScriptApp.getProjectTriggers();
  let removed = 0;
  for (let i = 0; i < triggers.length; i++) {
    if (triggers[i].getHandlerFunction() === 'routeApplications') {
      ScriptApp.deleteTrigger(triggers[i]);
      removed++;
    }
  }
  Logger.log('Removed ' + removed + ' routeApplications trigger(s).');
}
Install one recurring trigger

The controls are ready. Running the installer once creates the schedule that calls routeApplications under your account.

Apps Script may request authorization because the trigger accesses your Gmail data. The router still creates drafts without sending them.

  • Select installRouterTrigger from the function selector in the Apps Script toolbar.

Before you run it, consider whether rerunning this installer would leave one trigger or create a duplicate.

  • Click Run in the Apps Script toolbar.
  • Choose your signed-in Google account if an authorization prompt appears.
  • Approve the requested access if prompted.
  • Wait for the execution to finish.

The run log first reports how many matching triggers were removed. It then shows A 10-minute routeApplications trigger was installed.

The automation switch is live: exactly one recurring trigger now checks the synthetic intake. Rerunning the installer still leaves one matching trigger because cleanup happens first.

Trigger installation not completing?

  • Confirm that both trigger functions are saved in Code.gs.
  • Select installRouterTrigger before using Run.
  • Complete the authorization flow with the same account that owns the Apps Script project.

Help me diagnose why installRouterTrigger did not finish.

Test the automatic route

One fresh unprocessed thread is the proof that the schedule works. Reusing synthetic CV B gives the trigger the same controlled evidence that the manual router handled earlier.

  • Return to Gmail from earlier.
  • Use the new-message control at the top left to start a message.
  • Address the message to your own Gmail address.
  • Enter Junior Data Analyst Application - CV C as the subject.
  • Attach the synthetic CV B PDF from earlier.
  • Send the message.

Why reuse CV B?

CV B gives the automated run a known evidence pattern. You can compare the result with the earlier manual route.

  • Open the newly received thread in Gmail.
  • Apply the CV-Router-Inbox label to the thread.

Before the trigger checks the thread, consider which outcome label the same CV evidence received during the manual test.

The wait can take the full 10 minutes, so a quiet inbox during that window is normal.

  • Wait up to 10 minutes for the trigger to run.
  • Refresh Gmail after the waiting period.
  • Open the third application thread.

You should see CV-Router-Rejected plus CV-Router-Processed on the thread. You should also see one unsent editable draft.

  • Open the draft inside the third application thread.

You should see the recruiter-only review block followed by the rejection response. The message remains editable and unsent.

  • Leave the draft unsent.
  • Compare the original CV A and CV B threads with their state from the previous step.

Each original thread should keep its existing outcome label and processed label. Neither thread should gain another draft because the intake query excludes processed threads.

Third application still waiting?

  • Confirm that the new thread has the CV-Router-Inbox label.
  • Confirm that the thread contains a PDF attachment.
  • Run installRouterTrigger again if the installation did not complete.

Help me find why my third synthetic application was not routed after 10 minutes.

You have closed the final manual gap. New synthetic applications now move through evidence extraction and deterministic routing while every reply stays under human control.

Secret mission

Break and Repair the Conditional Branch

A single operator can quietly weaken an otherwise convincing application router. Introduce a controlled branching bug with synthetic evidence. Then trace the false acceptance and restore the all-requirements condition.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep the prototype active, pause its automation, or remove every training artifact. The prototype costs $0 while usage stays within the Gemini API free tier plus Google Apps Script consumer quotas.

Resources you used:

  • Standalone Google Apps Script project CV Application Router.
  • Gemini API key stored as the GEMINI_API_KEY script property.
  • Installable 10-minute trigger for routeApplications.
  • Four Gmail labels: CV-Router-Inbox, CV-Router-Accepted, CV-Router-Rejected, plus CV-Router-Processed.
  • Synthetic Google Docs files plus downloaded PDF CV files.
  • Synthetic Gmail test threads plus unsent editable drafts.

Keep everything running

No action needed. Choose this if you want to continue testing the router with synthetic applications.

  • Leave the routeApplications trigger installed only while using synthetic applications.
  • Keep CV Application Router available for future experiments.
  • Keep the GEMINI_API_KEY script property private.
  • Review every generated draft before choosing whether to send it manually.
  • Continue using synthetic CVs because free-tier Gemini content may be used to improve Google products.

Pause - I'll come back to this later

Stop recurring inbox checks while keeping the code, labels, synthetic files, test threads, plus drafts available for later.

  • In the Apps Script editor for CV Application Router, select removeRouterTriggers from the function list.
  • Click Run in the editor toolbar.

You should see Removed 1 routeApplications trigger(s). in the execution log. The router now stays idle until you install its trigger again.

Your code, labels, emails, files, plus unsent drafts remain available. No outgoing email is sent while the automation is paused.

Delete - I don't want to use this again

Remove all project resources to finish with a clean workspace. This cleanup affects only the synthetic training artifacts you created.

  • In the Apps Script editor for CV Application Router, select removeRouterTriggers from the function list.
  • Click Run in the editor toolbar.

You should see Removed 1 routeApplications trigger(s). in the execution log. This confirms that recurring intake checks have stopped.

  • Delete all synthetic application threads from Gmail.
  • Delete every remaining unsent router draft from Gmail.
  • Delete the CV-Router-Inbox, CV-Router-Accepted, CV-Router-Rejected, plus CV-Router-Processed labels from Gmail.
  • Delete the synthetic CV documents from Google Docs.
  • Delete the downloaded synthetic PDF CV files from the folder where your browser saved them.
  • Delete CV Application Router from the Apps Script project list.

Deleting the Gemini API key stops this prototype from authenticating with Gemini. The key is no longer needed after you delete the Apps Script project.

  • Delete the Gemini API key for this project from Google AI Studio.
  • Confirm that the trigger, project, key, labels, synthetic files, test threads, plus drafts no longer appear in their respective tools.

Nice Work!

Nice Work!

You did it! You built a Gmail router that uses Gemini to extract job evidence from synthetic PDF applications.

What you learned:

  • Built a Gmail intake flow that finds labeled synthetic applications with PDF CV attachments.
  • Used structured JSON to extract explicit job evidence. Validated every response before it reached the routing branch.
  • Applied an explicit JavaScript condition to choose Accepted or Rejected. Installed a 10-minute trigger that creates editable drafts without sending them.
  • Secret Mission: reproduced a false acceptance with ||. Restored && to verify that the corrected branch routes the same weak evidence to Rejected.

Ready to quiz yourself?