<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Raphaël sellami</title>
    <description>The latest articles on DEV Community by Raphaël sellami (@ir_raphalsellami_f30ecb).</description>
    <link>https://dev.to/ir_raphalsellami_f30ecb</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4241838%2F75cfb45a-d00b-4e7f-bce9-d030c1e29e2d.jpg</url>
      <title>DEV Community: Raphaël sellami</title>
      <link>https://dev.to/ir_raphalsellami_f30ecb</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ir_raphalsellami_f30ecb"/>
    <language>en</language>
    <item>
      <title>Routing inbound CV e-mails to the right job opening, without an LLM</title>
      <dc:creator>Raphaël sellami</dc:creator>
      <pubDate>Thu, 12 Nov 2026 22:08:44 +0000</pubDate>
      <link>https://dev.to/ir_raphalsellami_f30ecb/routing-inbound-cv-e-mails-to-the-right-job-opening-without-an-llm-1ffp</link>
      <guid>https://dev.to/ir_raphalsellami_f30ecb/routing-inbound-cv-e-mails-to-the-right-job-opening-without-an-llm-1ffp</guid>
      <description>&lt;p&gt;&lt;em&gt;I build &lt;a href="https://hrspeeder.com/landing" rel="noopener noreferrer"&gt;HRSpeeder&lt;/a&gt;, an applicant tracking system (ATS) for small companies. This post&lt;br&gt;
describes how our inbound e-mail routing works in production today, including what we deliberately left out.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Small companies still receive most applications by e-mail. A candidate writes "Application – Warehouse operator",&lt;br&gt;
attaches a PDF, and sends it to a generic address. The recruiter then has to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;save the CV somewhere,&lt;/li&gt;
&lt;li&gt;figure out which job it is for,&lt;/li&gt;
&lt;li&gt;create a record in a spreadsheet or an ATS.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At 2 minutes per e-mail, 200 applications for one job means more than 6 hours of pure data entry, before reading a&lt;br&gt;
single CV. We wanted that number to be zero, without asking candidates to use a form.&lt;/p&gt;
&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;p&gt;Every organization gets a receiving address (&lt;code&gt;apply+&amp;lt;tag&amp;gt;@inbox.hrspeeder.com&lt;/code&gt;). Our mail server hands each message to a&lt;br&gt;
webhook, which:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;finds the organization from the &lt;code&gt;+tag&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;routes the e-mail to one of the organization's active job offers&lt;/strong&gt; (this post),&lt;/li&gt;
&lt;li&gt;stores the attachments and creates the candidate and the application,&lt;/li&gt;
&lt;li&gt;queues the CV for AI analysis (score against the job description, strengths, missing skills, summary).&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Routing, step by step
&lt;/h2&gt;

&lt;p&gt;We only consider the organization's &lt;strong&gt;active&lt;/strong&gt; offers. If there are none, the application goes straight to the review&lt;br&gt;
queue.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 1 — Title match on the subject
&lt;/h3&gt;

&lt;p&gt;Most candidates put the job title in the subject. We normalize both strings (lowercase, accents removed, punctuation&lt;br&gt;
stripped) and accept the offer if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the subject &lt;strong&gt;contains&lt;/strong&gt; the normalized title (titles longer than 5 characters only, to avoid "dev" matching
everything) → confidence 0.88, or&lt;/li&gt;
&lt;li&gt;the Levenshtein similarity is above 0.75 → confidence 0.85.
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;levenshtein&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;normalizedSubject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalizedTitle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;normalizedSubject&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalizedTitle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;normalizedSubject&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;normalizedTitle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;normalizedTitle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;offerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.88&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'TITRE_MATCH'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requiresReview&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Step 2 — Keyword score on subject + body
&lt;/h3&gt;

&lt;p&gt;Each offer has a list of required skills. The score is the share of those skills found as words in the normalized&lt;br&gt;
subject + body (3 of 5 skills → 0.6). The best offer wins if its score is above 0.4; its confidence is capped at 0.72,&lt;br&gt;
which is below our 0.75 review threshold:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.72&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;offerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'KEYWORD_MATCH'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requiresReview&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a keyword match &lt;strong&gt;attaches&lt;/strong&gt; the application but stores the inbound e-mail as &lt;code&gt;NEEDS_REVIEW&lt;/code&gt; (instead of &lt;code&gt;MATCHED&lt;/code&gt;),&lt;br&gt;
with the method and confidence, so it can be checked later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — The review queue
&lt;/h3&gt;

&lt;p&gt;Everything else is stored as &lt;code&gt;UNCLASSIFIED&lt;/code&gt;, the recruiter gets an "unclassified e-mail" notification, and the&lt;br&gt;
application waits in an "À classer" (to sort) list until someone picks the offer. No application is ever dropped or&lt;br&gt;
auto-rejected.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we left out (on purpose)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A job reference regex&lt;/strong&gt; (&lt;code&gt;ABC-1234&lt;/code&gt;). The extractor exists, but small companies rarely use references and
candidates rarely copy them. It is not active.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An LLM fallback.&lt;/strong&gt; The code path exists (a small model returning &lt;code&gt;{ offre_id, confidence }&lt;/code&gt;), but it is disabled in
production. Title + keywords + a human queue were enough for our volumes, and sending every e-mail body to another
processor is a GDPR question we did not need to open for routing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The AI we &lt;em&gt;do&lt;/em&gt; use is for the CV analysis itself, after routing, and its output is advisory: the score tells the&lt;br&gt;
recruiter which CVs to read first. In the EU, AI that screens job applications is a high-risk use under the AI Act, so&lt;br&gt;
the decision stays human by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Confidence is a product feature.&lt;/strong&gt; Storing the method and confidence with each routed e-mail makes every
decision explainable, and separates "matched" from "matched, needs review".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cheap heuristics first.&lt;/strong&gt; Subject-contains-title catches the majority of real e-mails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never lose an application.&lt;/strong&gt; A visible queue beats a clever model that silently guesses wrong.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;HRSpeeder is free to start (10 applications per month with AI analysis included). Plans, data hosting (EU) and FAQ:&lt;br&gt;
&lt;a href="https://hrspeeder.com/reponses" rel="noopener noreferrer"&gt;https://hrspeeder.com/reponses&lt;/a&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>nextjs</category>
      <category>recruiting</category>
      <category>nlp</category>
    </item>
  </channel>
</rss>
