From f9530b168f2b2a617c5183023bb814ba4641d569 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 18:37:26 +0300 Subject: [PATCH 1/8] Restore AddHtmlShellTemplates migration with copyright symbol fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restored email.html-shell.start and email.html-shell.end templates to InitialSchema migration - Fixed copyright symbol: changed © to © HTML entity (avoids encoding issues in database) - These templates wrap plain text email bodies in proper HTML structure - Migration runs after InitialSchema, seeding the HTML wrapper templates Co-Authored-By: Claude Haiku 4.5 --- ...01145256_AddHtmlShellTemplates.Designer.cs | 65 ++++++++++--------- .../20260601145256_AddHtmlShellTemplates.cs | 6 +- 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.Designer.cs b/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.Designer.cs index ef4508b..99112d4 100644 --- a/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.Designer.cs +++ b/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using Email.Data; using Microsoft.EntityFrameworkCore; @@ -18,7 +18,7 @@ namespace Email.Data.Migrations /// protected override void BuildTargetModel(ModelBuilder modelBuilder) { -#pragma warning disable 612, 618 + #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("email") .HasAnnotation("ProductVersion", "10.0.7") @@ -27,43 +27,44 @@ namespace Email.Data.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b => - { - b.Property("Key") - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); - b.Property("Language") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); - b.Property("Description") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); - b.Property("OperatorCopy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)") - .HasDefaultValue(""); + b.Property("OperatorCopy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasDefaultValue(""); - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); - b.Property("Value") - .IsRequired() - .HasColumnType("nvarchar(max)"); + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); - b.HasKey("Key", "Language"); + b.HasKey("Key", "Language"); - b.ToTable("Templates", "email"); - }); -#pragma warning restore 612, 618 + b.ToTable("Templates", "email"); + }); + + #pragma warning restore 612, 618 } } } diff --git a/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.cs b/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.cs index 6376b45..4fc5703 100644 --- a/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.cs +++ b/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using Email.Data; #nullable disable @@ -15,14 +15,14 @@ namespace Email.Data.Migrations migrationBuilder.InsertData( table: "Templates", columns: new[] { "Key", "Language", "Value", "Description" }, - values: new object[] { "email.html-shell.start", "*", "\n\n\n \n \n \n\n\n
\n
\n

MyAi.ro

\n
\n
\n", "Opening HTML wrapper for branded emails (blue header, white content area)" }, + values: new object[] { "email.html-shell.start", "*", "\n\n\n \n \n \n\n\n
\n
\n

MyAi.ro

\n
\n
\n", "Opening HTML wrapper for branded emails (blue header, white content area)" }, schema: MigrationConstants.SchemaName); // HTML email shell — closing tags (footer) migrationBuilder.InsertData( table: "Templates", columns: new[] { "Key", "Language", "Value", "Description" }, - values: new object[] { "email.html-shell.end", "*", "
\n
\n

© 2026 MyAi.ro. All rights reserved.

\n
\n
\n\n\n", "Closing HTML wrapper for branded emails (footer and closing tags)" }, + values: new object[] { "email.html-shell.end", "*", "
\n
\n

© 2026 MyAi.ro. All rights reserved.

\n
\n
\n\n\n", "Closing HTML wrapper for branded emails (footer and closing tags)" }, schema: MigrationConstants.SchemaName); } -- 2.52.0 From 978dd3a069561aefe97401abda1c50ce451e9cc6 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 19:16:02 +0300 Subject: [PATCH 2/8] Update email templates to HTML format and fix EmailApiEmailSender MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert email.match.body, email.match.job-search-footer, email.search-results.body, and email.search-results.empty templates from plain text to proper HTML format in InitialSchema migration - Update EmailApiEmailSender.BuildMatchEmailBody() to work with HTML templates instead of plain text - Add WebUtility.HtmlEncode() for security when inserting dynamic content (summary) - Templates now use semantic HTML tags (table, h2, h3, ul, li, p, div, hr, a) instead of plain text with newlines - All 32 email template variants (16 keys × 2 languages) and 8 html.job-search.* templates seeded via migration Co-Authored-By: Claude Haiku 4.5 --- Apis/api/Services/EmailApiEmailSender.cs | 19 +-- .../20260601133043_InitialSchema.cs | 116 +++++++++++++++--- 2 files changed, 112 insertions(+), 23 deletions(-) diff --git a/Apis/api/Services/EmailApiEmailSender.cs b/Apis/api/Services/EmailApiEmailSender.cs index cd51feb..384515d 100644 --- a/Apis/api/Services/EmailApiEmailSender.cs +++ b/Apis/api/Services/EmailApiEmailSender.cs @@ -6,6 +6,7 @@ using EmailApi.Models.Requests; using Microsoft.Extensions.Options; using Models.Requests; using Models.Settings; +using System.Net; namespace Api.Services; @@ -194,31 +195,35 @@ public sealed class EmailApiEmailSender : IEmailSender /// public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7) { + // Build HTML lists for strengths, gaps, and recommendations var strengths = result.Strengths?.Count > 0 - ? "
    " + + ? "
      " + string.Join("", result.Strengths.Select(s => $"
    • {s}
    • ")) + "
    " - : "

    "; + : "

    "; var gaps = result.Gaps?.Count > 0 - ? "
      " + + ? "
        " + string.Join("", result.Gaps.Select(g => $"
      • {g}
      • ")) + "
      " - : "

      "; + : "

      "; var recommendations = result.Recommendations?.Count > 0 - ? "
        " + + ? "
          " + string.Join("", result.Recommendations.Select(r => $"
        • {r}
        • ")) + "
        " - : "

        "; + : "

        "; + // Render the HTML template with substituted values + // email.match.body is now stored as HTML in the database var body = _emailTemplates.Render("email.match.body", language, ("cvDocumentId", cvDocumentId), ("jobLabel", jobLabel ?? "N/A"), ("jobUrl", result.JobUrl ?? "N/A"), ("score", result.Score.ToString()), - ("summary", result.Summary ?? string.Empty), + ("summary", WebUtility.HtmlEncode(result.Summary ?? string.Empty)), ("strengths", strengths), ("gaps", gaps), ("recommendations", recommendations)); + // Append the job search footer if link is provided if (!string.IsNullOrWhiteSpace(jobSearchLink)) { body += _emailTemplates.Render("email.match.job-search-footer", language, diff --git a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs index 0568ed2..b790a60 100644 --- a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs +++ b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs @@ -44,33 +44,117 @@ namespace Email.Data.Migrations Row("email.match.subject", "en", "MyAi.ro CV Match: {{score}}% - {{jobLabel}}", "Subject for the CV match result email"); Row("email.match.subject", "ro", "MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}", "Subiect email rezultat potrivire CV"); - // Match result email — body + // Match result email — body (HTML formatted) Row("email.match.body", "en", - "CV Matcher result\n\nCV Document ID: {{cvDocumentId}}\nJob: {{jobLabel}}\nJob URL: {{jobUrl}}\nScore: {{score}}%\n\nSummary:\n{{summary}}\n\nStrengths:\n{{strengths}}\n\nGaps:\n{{gaps}}\n\nRecommendations:\n{{recommendations}}", - "Body for the CV match result email"); + @"

        CV Match Report

        + + + + + + + + + + + + + + + + + +
        CV ID{{cvDocumentId}}
        Job{{jobLabel}}
        URL{{jobUrl}}
        Score{{score}}%
        +

        Summary

        +

        {{summary}}

        +

        Strengths

        +
        {{strengths}}
        +

        Gaps

        +
        {{gaps}}
        +

        Recommendations

        +
        {{recommendations}}
        ", + "Body for the CV match result email (HTML formatted)"); Row("email.match.body", "ro", - "Rezultat potrivire CV\n\nID document CV: {{cvDocumentId}}\nJob: {{jobLabel}}\nURL job: {{jobUrl}}\nScor: {{score}}%\n\nRezumat:\n{{summary}}\n\nPuncte forte:\n{{strengths}}\n\nLipsuri:\n{{gaps}}\n\nRecomandări:\n{{recommendations}}", - "Corpul emailului pentru rezultatul potrivirii CV"); + @"

        Report Potrivire CV

        + + + + + + + + + + + + + + + + + +
        ID Document CV{{cvDocumentId}}
        Job{{jobLabel}}
        URL{{jobUrl}}
        Scor{{score}}%
        +

        Rezumat

        +

        {{summary}}

        +

        Puncte Forte

        +
        {{strengths}}
        +

        Lipsuri

        +
        {{gaps}}
        +

        Recomandări

        +
        {{recommendations}}
        ", + "Corpul emailului pentru rezultatul potrivirii CV (format HTML)"); - // Match result email — job search CTA footer + // Match result email — job search CTA footer (HTML formatted) Row("email.match.job-search-footer", "en", - "\n\n---\nWant to find more jobs matching your CV?\nClick: {{jobSearchLink}}\n(link valid for {{expiryDays}} days)", - "Job search CTA appended to match result email"); + @"
        +

        Want to find more jobs matching your CV?

        +

        Search Jobs

        +

        (link valid for {{expiryDays}} days)

        ", + "Job search CTA appended to match result email (HTML formatted)"); Row("email.match.job-search-footer", "ro", - "\n\n---\nVrei sa gasesti mai multe joburi potrivite CV-ului tau?\nClick: {{jobSearchLink}}\n(link valabil {{expiryDays}} zile)", - "CTA cautare joburi adaugat la emailul de potrivire CV"); + @"
        +

        Vrei să găsești mai multe joburi potrivite CV-ului tău?

        +

        Caută Joburi

        +

        (link valabil {{expiryDays}} zile)

        ", + "CTA cautare joburi adaugat la emailul de potrivire CV (format HTML)"); // Job search results email — subject Row("email.search-results.subject", "en", "MyAi.ro: {{count}} jobs matching your CV", "Subject for job search results email"); Row("email.search-results.subject", "ro", "MyAi.ro: {{count}} joburi potrivite CV-ului tau", "Subiect email rezultate cautare joburi"); - // Job search results email — body preamble (items appended in code) - Row("email.search-results.body", "en", "MyAi.ro found {{count}} jobs matching your CV:\n\n{{items}}", "Body preamble for job search results email"); - Row("email.search-results.body", "ro", "MyAi.ro a gasit {{count}} joburi potrivite CV-ului tau:\n\n{{items}}", "Corpul emailului de rezultate cautare joburi"); + // Job search results email — body preamble (items appended in code) - HTML formatted + Row("email.search-results.body", "en", + @"

        Job Search Results

        +

        MyAi.ro found {{count}} jobs matching your CV:

        +
        +{{items}} +
        ", + "Body preamble for job search results email (HTML formatted)"); + Row("email.search-results.body", "ro", + @"

        Rezultate Căutare Joburi

        +

        MyAi.ro a găsit {{count}} joburi potrivite CV-ului tău:

        +
        +{{items}} +
        ", + "Corpul emailului de rezultate cautare joburi (format HTML)"); - // Job search results email — no results found - Row("email.search-results.empty", "en", "MyAi.ro found no jobs matching your CV. Try again later or update your CV.", "No results message for job search results email"); - Row("email.search-results.empty", "ro", "MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul.", "Mesaj fara rezultate pentru emailul de cautare joburi"); + // Job search results email — no results found - HTML formatted + Row("email.search-results.empty", "en", + @"
        +

        +No jobs found
        +MyAi.ro found no jobs matching your CV at this moment. Please try again later or update your CV to improve your match results. +

        +
        ", + "No results message for job search results email (HTML formatted)"); + Row("email.search-results.empty", "ro", + @"
        +

        +Niciun job găsit
        +MyAi.ro nu a găsit joburi potrivite CV-ului tău în acest moment. Te rugăm să încerci din nou mai târziu sau să-ți actualizezi CV-ul pentru a obține rezultate mai bune. +

        +
        ", + "Mesaj fara rezultate pentru emailul de cautare joburi (format HTML)"); // HTML job-search start page messages Row("html.job-search.started.title", "en", "Job search started", "Title for job search started page"); -- 2.52.0 From 8f90a4cfda1a89c851738b04e31a50ceafb84d20 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 19:18:26 +0300 Subject: [PATCH 3/8] Reduce email match table width to 500px max-width, centered - Changed table width from 100% to max-width: 500px with margin: 0 auto - Applies to both English and Romanian email.match.body templates - Table now narrower and centered in email Co-Authored-By: Claude Haiku 4.5 --- Apis/email-data/Migrations/20260601133043_InitialSchema.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs index b790a60..567a214 100644 --- a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs +++ b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs @@ -47,7 +47,7 @@ namespace Email.Data.Migrations // Match result email — body (HTML formatted) Row("email.match.body", "en", @"

        CV Match Report

        - +
        @@ -76,7 +76,7 @@ namespace Email.Data.Migrations "Body for the CV match result email (HTML formatted)"); Row("email.match.body", "ro", @"

        Report Potrivire CV

        -
        CV ID {{cvDocumentId}}
        +
        -- 2.52.0 From 2838885e22925fefc12b27222ac53f22ccd36c85 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 20:01:58 +0300 Subject: [PATCH 4/8] Fix email templates for Outlook compatibility and move HTML out of code - Replace div-based layouts with table-based HTML throughout (max-width/border-radius/display:inline-block ignored by Outlook) - email.match.body: width:100% table with per-cell borders and fixed 130px label column - email.match.job-search-footer: table-based button with bgcolor attribute - email.search-results.empty: div replaced with full-width table - email.search-results.body: remove div wrapper around items - Add email.search-results.scan-summary and email.search-results.item templates - CvSearchEmailSender: remove all hardcoded HTML; render via IEmailTemplateService Co-Authored-By: Claude Sonnet 4.6 --- .../20260601133043_InitialSchema.cs | 138 +++++++++++++----- .../Services/CvSearchEmailSender.cs | 44 +++--- 2 files changed, 121 insertions(+), 61 deletions(-) diff --git a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs index 567a214..aeb0295 100644 --- a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs +++ b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs @@ -47,22 +47,22 @@ namespace Email.Data.Migrations // Match result email — body (HTML formatted) Row("email.match.body", "en", @"

        CV Match Report

        -
        ID Document CV {{cvDocumentId}}
        +
        - - + + - - + + - - + + - - + +
        CV ID{{cvDocumentId}}CV ID{{cvDocumentId}}
        Job{{jobLabel}}Job{{jobLabel}}
        URL{{jobUrl}}URL{{jobUrl}}
        Score{{score}}%Score{{score}}%

        Summary

        @@ -76,22 +76,22 @@ namespace Email.Data.Migrations "Body for the CV match result email (HTML formatted)"); Row("email.match.body", "ro", @"

        Report Potrivire CV

        - +
        - - + + - - + + - - + + - - + +
        ID Document CV{{cvDocumentId}}ID Document CV{{cvDocumentId}}
        Job{{jobLabel}}Job{{jobLabel}}
        URL{{jobUrl}}URL{{jobUrl}}
        Scor{{score}}%Scor{{score}}%

        Rezumat

        @@ -108,13 +108,25 @@ namespace Email.Data.Migrations Row("email.match.job-search-footer", "en", @"

        Want to find more jobs matching your CV?

        -

        Search Jobs

        + + + + +
        + Search Jobs +

        (link valid for {{expiryDays}} days)

        ", "Job search CTA appended to match result email (HTML formatted)"); Row("email.match.job-search-footer", "ro", @"

        Vrei să găsești mai multe joburi potrivite CV-ului tău?

        -

        Caută Joburi

        + + + + +
        + Caută Joburi +

        (link valabil {{expiryDays}} zile)

        ", "CTA cautare joburi adaugat la emailul de potrivire CV (format HTML)"); @@ -126,34 +138,84 @@ namespace Email.Data.Migrations Row("email.search-results.body", "en", @"

        Job Search Results

        MyAi.ro found {{count}} jobs matching your CV:

        -
        -{{items}} -
        ", +{{items}}", "Body preamble for job search results email (HTML formatted)"); Row("email.search-results.body", "ro", @"

        Rezultate Căutare Joburi

        MyAi.ro a găsit {{count}} joburi potrivite CV-ului tău:

        -
        -{{items}} -
        ", +{{items}}", "Corpul emailului de rezultate cautare joburi (format HTML)"); + // Job search results email — scan summary block (keywords + providers used) + Row("email.search-results.scan-summary", "en", + @" + + + +
        +
        Keywords used: {{keywordsHtml}}
        +
        Providers scanned: {{providers}}
        +
        ", + "Scan summary block prepended to job search results email (HTML formatted)"); + Row("email.search-results.scan-summary", "ro", + @" + + + +
        +
        Cuvinte cheie folosite: {{keywordsHtml}}
        +
        Furnizori scanați: {{providers}}
        +
        ", + "Bloc rezumat scanare adaugat la emailul de rezultate cautare joburi (format HTML)"); + + // Job search results email — single job result item card + Row("email.search-results.item", "en", + @" + + + +
        + {{index}}. {{jobTitle}} + {{score}}% match + [{{providerName}}]
        + {{jobUrl}} + {{summary}} +
        ", + "Single job result card in job search results email (HTML formatted)"); + Row("email.search-results.item", "ro", + @" + + + +
        + {{index}}. {{jobTitle}} + {{score}}% potrivire + [{{providerName}}]
        + {{jobUrl}} + {{summary}} +
        ", + "Card job individual in emailul de rezultate cautare joburi (format HTML)"); + // Job search results email — no results found - HTML formatted Row("email.search-results.empty", "en", - @"
        -

        -No jobs found
        -MyAi.ro found no jobs matching your CV at this moment. Please try again later or update your CV to improve your match results. -

        -
        ", + @" + + + +
        +

        No jobs found
        + MyAi.ro found no jobs matching your CV at this moment. Please try again later or update your CV to improve your match results.

        +
        ", "No results message for job search results email (HTML formatted)"); Row("email.search-results.empty", "ro", - @"
        -

        -Niciun job găsit
        -MyAi.ro nu a găsit joburi potrivite CV-ului tău în acest moment. Te rugăm să încerci din nou mai târziu sau să-ți actualizezi CV-ul pentru a obține rezultate mai bune. -

        -
        ", + @" + + + +
        +

        Niciun job găsit
        + MyAi.ro nu a găsit joburi potrivite CV-ului tău în acest moment. Te rugăm să încerci din nou mai târziu sau să-ți actualizezi CV-ul pentru a obține rezultate mai bune.

        +
        ", "Mesaj fara rezultate pentru emailul de cautare joburi (format HTML)"); // HTML job-search start page messages diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs index 3262d8e..08fac2b 100644 --- a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -89,7 +89,7 @@ public sealed class CvSearchEmailSender /// private string BuildBody(IReadOnlyList results, IReadOnlyList keywords, IReadOnlyList providerNames, string language) { - var scanSummary = BuildScanSummary(keywords, providerNames); + var scanSummary = BuildScanSummary(keywords, providerNames, language); if (results.Count == 0) return scanSummary + _emailTemplates.Get("email.search-results.empty", language); @@ -98,18 +98,18 @@ public sealed class CvSearchEmailSender for (int i = 0; i < results.Count; i++) { var r = results[i]; - var matchResp = TryParseResult(r.ResultJson); - var summary = matchResp?.Summary; + var summary = TryParseResult(r.ResultJson)?.Summary; + var summaryHtml = string.IsNullOrWhiteSpace(summary) + ? "" + : $"

        {summary}

        "; - items.Append($""" -
        - {i + 1}. {r.JobTitle} - {r.Score}% match - [{r.ProviderName}]
        - {r.JobUrl} - {(string.IsNullOrWhiteSpace(summary) ? "" : $"

        {summary}

        ")} -
        - """); + items.Append(_emailTemplates.Render("email.search-results.item", language, + ("index", (i + 1).ToString()), + ("jobTitle", r.JobTitle), + ("score", r.Score.ToString()), + ("providerName", r.ProviderName), + ("jobUrl", r.JobUrl), + ("summary", summaryHtml))); } return _emailTemplates.Render("email.search-results.body", language, @@ -118,25 +118,23 @@ public sealed class CvSearchEmailSender } /// - /// Builds the scan summary block showing the CV keywords and providers used for the search. + /// Renders the scan summary block via template, passing keyword tags and provider list as data. + /// Keyword tags are built here because they are variable-count inline elements, not structural HTML. /// - private static string BuildScanSummary(IReadOnlyList keywords, IReadOnlyList providerNames) + private string BuildScanSummary(IReadOnlyList keywords, IReadOnlyList providerNames, string language) { var keywordsHtml = keywords.Count > 0 ? string.Join(" ", keywords.Select(k => - $"{k}")) - : "none detected"; + $"{k}")) + : "none detected"; - var providersText = providerNames.Count > 0 + var providers = providerNames.Count > 0 ? string.Join(", ", providerNames) : "none"; - return $""" -
        -
        Keywords used: {keywordsHtml}
        -
        Providers scanned: {providersText}
        -
        - """; + return _emailTemplates.Render("email.search-results.scan-summary", language, + ("keywordsHtml", keywordsHtml), + ("providers", providers)); } /// -- 2.52.0 From b5b654532c8f4a495d212289866c24c45e55f200 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 20:06:55 +0300 Subject: [PATCH 5/8] Fix HTML shell templates to use table-based layout (Outlook-safe) Replace div/CSS-class approach with nested table layout so the 600px container is enforced via HTML attributes, not a \n\n\n
        \n
        \n

        MyAi.ro

        \n
        \n
        \n", "Opening HTML wrapper for branded emails (blue header, white content area)" }, + values: new object[] { "email.html-shell.start", "*", "\n\n\n \n \n\n\n \n \n \n \n
        \n \n \n \n \n \n \n \n \n \n \n
        \n

        MyAi.ro

        \n
        \n", "Opening HTML wrapper for branded emails (blue header, white content area)" }, schema: MigrationConstants.SchemaName); // HTML email shell — closing tags (footer) migrationBuilder.InsertData( table: "Templates", columns: new[] { "Key", "Language", "Value", "Description" }, - values: new object[] { "email.html-shell.end", "*", " \n
        \n

        © 2026 MyAi.ro. All rights reserved.

        \n
        \n \n\n\n", "Closing HTML wrapper for branded emails (footer and closing tags)" }, + values: new object[] { "email.html-shell.end", "*", "\n
        \n

        © 2026 MyAi.ro. All rights reserved.

        \n
        \n
        \n\n\n", "Closing HTML wrapper for branded emails (footer and closing tags)" }, schema: MigrationConstants.SchemaName); } -- 2.52.0 From 808a4901d972a5aa7d002e829f1fb67423bff918 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 20:13:00 +0300 Subject: [PATCH 6/8] Add keywords field to AI CV-match system prompt The LLM JSON shape was missing the keywords array so res.Keywords was always empty, causing "none detected" in job search emails. Both en/ro prompts now include "keywords" in the required JSON shape so the LLM extracts relevant job-search terms from the CV/job pair. Note: the cvMatcher.CvMatchResults cache must be cleared on existing DBs so cached responses (which lack keywords) are not served. Co-Authored-By: Claude Sonnet 4.6 --- .../Migrations/20260601133028_InitialSchema.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs b/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs index 13e650c..78e799e 100644 --- a/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs +++ b/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs @@ -82,12 +82,12 @@ namespace CvMatcher.Data.Migrations // AI system prompt for CV matching — English Row("ai.cv-match.system-prompt", "en", - "You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\",\"strength 2 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"]}", + "You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"keyword1\",\"keyword2\",\"keyword3\"]}", "System prompt for CV-to-job matching in English. Instructs LLM to return JSON with CV strengths, gaps, and recommendations relative to the job."); // AI system prompt for CV matching — Romanian Row("ai.cv-match.system-prompt", "ro", - "Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\",\"punct forte 2 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"]}", + "Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"cuvant1\",\"cuvant2\",\"cuvant3\"]}", "System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job."); } -- 2.52.0 From 7a316b4a4505bd16cac5fe7b1c701c96f9529803 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 20:17:58 +0300 Subject: [PATCH 7/8] Move hardcoded HtmlPage shell into html.job-search.shell DB template The job-search status page HTML wrapper was baked into a static helper method in CvMatcherController. Extracted to a new template key html.job-search.shell (*) with {{title}} and {{message}} placeholders. Added to AddTemplates seed and a new AddHtmlJobSearchShell migration for existing DBs. Controller now calls _templates.Render() for all paths. Co-Authored-By: Claude Sonnet 4.6 --- Apis/api/Controllers/CvMatcherController.cs | 32 +++++----------- .../Migrations/20260524145351_AddTemplates.cs | 5 +++ .../20260601190000_AddHtmlJobSearchShell.cs | 37 +++++++++++++++++++ 3 files changed, 51 insertions(+), 23 deletions(-) create mode 100644 Apis/myai-data/Migrations/20260601190000_AddHtmlJobSearchShell.cs diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index 03098f2..3b0bcc1 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -246,38 +246,24 @@ public sealed class CvMatcherController : ControllerBase { var result = await _jobSearchApi.StartSearchAsync(t, ct); var lang = "en"; - var html = result.Status switch + var (title, message) = result.Status switch { - StartJobSearchStatus.Started => - HtmlPage(_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)), - StartJobSearchStatus.AlreadyUsed => - HtmlPage(_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)), - StartJobSearchStatus.Expired => - HtmlPage(_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)), - _ => - HtmlPage(_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang)) + StartJobSearchStatus.Started => (_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)), + StartJobSearchStatus.AlreadyUsed => (_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)), + StartJobSearchStatus.Expired => (_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)), + _ => (_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang)) }; - return Content(html, "text/html"); + return Content(_templates.Render("html.job-search.shell", "*", ("title", title), ("message", message)), "text/html"); } catch (Exception ex) { _logger.LogError(ex, "Job search start failed for token {Token}.", t); - return Content(HtmlPage(_templates.Get("html.job-search.error.title", "en"), _templates.Get("html.job-search.error.message", "en")), "text/html"); + var title = _templates.Get("html.job-search.error.title", "en"); + var message = _templates.Get("html.job-search.error.message", "en"); + return Content(_templates.Render("html.job-search.shell", "*", ("title", title), ("message", message)), "text/html"); } } - private static string HtmlPage(string title, string message) => $$""" - - - {{title}} - MyAi.ro - - -

        {{title}}

        {{message}}

        - - """; - private async Task CacheUploadedCvAsync(IFormFile file, string documentId, CancellationToken ct) { try diff --git a/Apis/myai-data/Migrations/20260524145351_AddTemplates.cs b/Apis/myai-data/Migrations/20260524145351_AddTemplates.cs index 7099ee3..3b68ef7 100644 --- a/Apis/myai-data/Migrations/20260524145351_AddTemplates.cs +++ b/Apis/myai-data/Migrations/20260524145351_AddTemplates.cs @@ -71,6 +71,11 @@ namespace MyAi.Data.Migrations Row("email.search-results.empty", "en", "MyAi.ro found no jobs matching your CV. Try again later or update your CV.", "No results message for job search results email"); Row("email.search-results.empty", "ro", "MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul.", "Mesaj fara rezultate pentru emailul de cautare joburi"); + // HTML job-search page shell — wraps title + message in a centered card page + Row("html.job-search.shell", "*", + "{{title}} - MyAi.ro

        {{title}}

        {{message}}

        ", + "Full HTML shell for job-search status pages. Placeholders: {{title}}, {{message}}."); + // HTML job-search start page messages Row("html.job-search.started.title", "en", "Job search started", "Title for job search started page"); Row("html.job-search.started.message", "en", "Your job search has started. Results will be sent to your email shortly.", "Message for job search started page"); diff --git a/Apis/myai-data/Migrations/20260601190000_AddHtmlJobSearchShell.cs b/Apis/myai-data/Migrations/20260601190000_AddHtmlJobSearchShell.cs new file mode 100644 index 0000000..87b638a --- /dev/null +++ b/Apis/myai-data/Migrations/20260601190000_AddHtmlJobSearchShell.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using MyAi.Data; + +#nullable disable + +namespace MyAi.Data.Migrations +{ + /// + public partial class AddHtmlJobSearchShell : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.InsertData( + table: "Templates", + columns: ["Key", "Language", "Value", "Description"], + values: new object[] + { + "html.job-search.shell", + "*", + "{{title}} - MyAi.ro

        {{title}}

        {{message}}

        ", + "Full HTML shell for job-search status pages. Placeholders: {{title}}, {{message}}." + }, + schema: MigrationConstants.SchemaName); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: new object[] { "html.job-search.shell", "*" }, + schema: MigrationConstants.SchemaName); + } + } +} -- 2.52.0 From 4066ab5f3f9c97565cd74a861a90ccf963aeb876 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 20:22:29 +0300 Subject: [PATCH 8/8] Remove duplicate html.job-search.* rows from email.Templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These templates belong to the myAi schema (myai-data) and are read by CvMatcherController via ITemplateService. The email-data copies were never read by any code — removing them to avoid confusion. Co-Authored-By: Claude Sonnet 4.6 --- .../20260601133043_InitialSchema.cs | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs index aeb0295..98a2518 100644 --- a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs +++ b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs @@ -218,31 +218,6 @@ namespace Email.Data.Migrations ", "Mesaj fara rezultate pentru emailul de cautare joburi (format HTML)"); - // HTML job-search start page messages - Row("html.job-search.started.title", "en", "Job search started", "Title for job search started page"); - Row("html.job-search.started.message", "en", "Your job search has started. Results will be sent to your email shortly.", "Message for job search started page"); - Row("html.job-search.started.title", "ro", "Căutare joburi pornită", "Titlu pagina cautare joburi pornita"); - Row("html.job-search.started.message", "ro", "Căutarea joburilor a început. Rezultatele vor fi trimise pe email în scurt timp.", "Mesaj pagina cautare joburi pornita"); - - Row("html.job-search.already-used.title", "en", "Link already used", "Title for already-used page"); - Row("html.job-search.already-used.message", "en", "This job search link has already been used.", "Message for already-used page"); - Row("html.job-search.already-used.title", "ro", "Link deja folosit", "Titlu pagina link deja folosit"); - Row("html.job-search.already-used.message", "ro", "Acest link de cautare joburi a fost deja folosit.", "Mesaj pagina link deja folosit"); - - Row("html.job-search.expired.title", "en", "Link expired", "Title for expired link page"); - Row("html.job-search.expired.message", "en", "This job search link has expired. Please request a new CV match to get a fresh link.", "Message for expired link page"); - Row("html.job-search.expired.title", "ro", "Link expirat", "Titlu pagina link expirat"); - Row("html.job-search.expired.message", "ro", "Acest link de cautare joburi a expirat. Solicita o noua potrivire CV pentru a primi un link nou.", "Mesaj pagina link expirat"); - - Row("html.job-search.invalid.title", "en", "Invalid link", "Title for invalid link page"); - Row("html.job-search.invalid.message", "en", "This job search link is not valid.", "Message for invalid link page"); - Row("html.job-search.invalid.title", "ro", "Link invalid", "Titlu pagina link invalid"); - Row("html.job-search.invalid.message", "ro", "Acest link de cautare joburi nu este valid.", "Mesaj pagina link invalid"); - - Row("html.job-search.error.title", "en", "Error", "Title for error page"); - Row("html.job-search.error.message", "en", "An error occurred. Please try again later.", "Message for error page"); - Row("html.job-search.error.title", "ro", "Eroare", "Titlu pagina eroare"); - Row("html.job-search.error.message", "ro", "A apărut o eroare. Te rugăm să încerci din nou mai târziu.", "Mesaj pagina eroare"); } /// -- 2.52.0