From ba92c9f793d2537f9430b0e3d231093c38d815f8 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 16:26:56 +0300 Subject: [PATCH] feat: wire email-api into docker-compose, .sln and CLAUDE.md - docker-compose: add email-api service (internal, no ports) with Smtp__* + FileStorage__Path + Files volume mount - api + cv-search-job: remove Smtp__* vars, add EmailApi__BaseUrl and EmailApi__InternalApiKey; add depends_on: email-api - .sln: move email-api-models to Models virtual folder - CLAUDE.md: add email-api/email-api-models to layout, update service dependency diagram and internal API key table Closes #22 Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 32 +++++++++++++------ docker-compose/docker-compose.yml | 51 +++++++++++++++++++++++++------ myAi.sln | 2 +- 3 files changed, 64 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 27a2b35..6443417 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ This applies to both the staging and production repos as appropriate. - Entity Framework Core + SQL Server (multi-schema) - Refit for typed HTTP clients between services - Serilog (JSON structured logging, Console + File + Email sinks) -- MailKit for SMTP +- MailKit for SMTP (used exclusively in `email-api`) - Docker Compose for local and production deployment - Watchtower for automatic container updates in production @@ -63,6 +63,8 @@ This applies to both the staging and production repos as appropriate. Apis/ api/ Public-facing proxy API (port 8080). Handles CORS, rate limiting, captcha, email. api-models/ DTOs and settings for api only. + email-api/ Internal SMTP email relay (no public port). All email sending goes here. + email-api-models/ Refit client + SendEmailRequest + EmailApiSettings (shared by api and cv-search-job). cv-matcher-api/ Internal CV match engine (port 8082). Runs CvMatcher + CvSearch + MyAi DB migrations. cv-matcher-api-models/ DTOs shared between api and cv-matcher-api (incl. JobSearchSettings). rag-api/ Internal RAG/vector-search service (port 8081). @@ -148,26 +150,36 @@ EF tools version warning ("older than runtime") is expected and harmless. The `H ``` web → api → cv-matcher-api → rag-api - ↑ - cv-search-job + ↓ ↓ + | email-api + ↓ ↑ +cv-search-job ``` +`api` and `cv-search-job` both call `email-api` for all outbound email (SMTP). `api` never talks directly to `rag-api` — always via `cv-matcher-api`. ## Internal API key auth -All internal service-to-service calls require the `X-Internal-Api-Key` header. -The key is shared via the `CvMatcherApi__InternalApiKey` and `RagApi__InternalApiKey` env vars. -`startup-helpers` provides `UseInternalApiKeyProtection()` middleware that enforces it on `cv-matcher-api` and `rag-api`. +All internal service-to-service calls require the `X-Internal-Api-Key` header. + +| Caller | Target | Env var for key | +|--------|--------|-----------------| +| `api`, `cv-search-job` | `email-api` | `EmailApi__InternalApiKey` | +| `api`, `cv-search-job` | `cv-matcher-api` | `CvMatcherApi__InternalApiKey` | +| `cv-matcher-api` | `rag-api` | `RagApi__InternalApiKey` | + +`startup-helpers` provides `UseInternalApiKeyProtection()` middleware (reads `InternalApi:ApiKey`); enforced on `cv-matcher-api`, `rag-api`, and `email-api`. ## Shared file storage -CV PDFs are written by `api` to `Apis/api/Files/` and read by `cv-cleanup-job` and `cv-search-job`. -All three containers mount the same bind volume: +CV PDFs are written by `api` to `Apis/api/Files/` and read by `cv-cleanup-job`, `cv-search-job`, and `email-api` (for email attachments). +All four containers mount the same bind volume: ```yaml -- ../Apis/api/Files:/app/Files +- ${FILES_PATH:-/opt/myai/files}:/app/Files ``` -The path inside containers is controlled by `FileStorage__Path` (default: `Files`). +The path inside containers is controlled by `FileStorage__Path` (default: `Files`). +`email-api` receives only the relative filename (e.g. `abc123.pdf`) and resolves it against `FileStorage__Path`. ## Job task pattern diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index e10f628..e873de2 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -100,11 +100,47 @@ services: labels: - "com.centurylinklabs.watchtower.enable=true" + email-api: + image: registry.easysoft.ro/apps/myai-email-api:${IMAGE_TAG:-staging} + container_name: myai-email-api + environment: + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} + - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} + + - InternalApi__ApiKey=${EmailApi__InternalApiKey:-} + - InternalApi__RequireApiKey=true + + - Smtp__Host=${Smtp__Host:-} + - Smtp__Port=${Smtp__Port:-587} + - Smtp__Username=${Smtp__Username:-} + - Smtp__Password=${Smtp__Password:-} + - Smtp__UseStartTls=${Smtp__UseStartTls:-false} + + - FileStorage__Path=${FileStorage__Path:-Files} + + - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} + - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} + - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} + - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} + - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} + - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} + - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} + volumes: + - ${LOGS_PATH:-/opt/myai/logs}/email-api:/app/logs + - ${FILES_PATH:-/opt/myai/files}:/app/Files + networks: + - myai-network + restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" + api: image: registry.easysoft.ro/apps/myai-api:${IMAGE_TAG:-staging} container_name: myai-api depends_on: - cv-matcher-api + - email-api environment: - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} @@ -126,11 +162,8 @@ services: - Subscribe__ToEmail=${Subscribe__ToEmail:-} - Subscribe__SubjectPrefix=${Subscribe__SubjectPrefix:-} - - Smtp__Host=${Smtp__Host:-} - - Smtp__Port=${Smtp__Port:-587} - - Smtp__Username=${Smtp__Username:-} - - Smtp__Password=${Smtp__Password:-} - - Smtp__UseStartTls=${Smtp__UseStartTls:-false} + - EmailApi__BaseUrl=${EmailApi__BaseUrl:-http://email-api:8080} + - EmailApi__InternalApiKey=${EmailApi__InternalApiKey:-} - Captcha__Provider=${Captcha__Provider:-Recaptcha} - Captcha__SecretKey=${Captcha__SecretKey:-} @@ -210,6 +243,7 @@ services: container_name: myai-cv-search-job depends_on: - cv-matcher-api + - email-api environment: - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} @@ -224,11 +258,8 @@ services: - CvMatcherApi__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080} - CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-} - - Smtp__Host=${Smtp__Host:-} - - Smtp__Port=${Smtp__Port:-587} - - Smtp__Username=${Smtp__Username:-} - - Smtp__Password=${Smtp__Password:-} - - Smtp__UseStartTls=${Smtp__UseStartTls:-false} + - EmailApi__BaseUrl=${EmailApi__BaseUrl:-http://email-api:8080} + - EmailApi__InternalApiKey=${EmailApi__InternalApiKey:-} - Contact__ToEmail=${Contact__ToEmail:-} diff --git a/myAi.sln b/myAi.sln index 14345c8..a937763 100644 --- a/myAi.sln +++ b/myAi.sln @@ -368,7 +368,7 @@ Global {92CA82EB-E558-44E7-9185-6FF8B8299C2A} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} {02DE69CD-19E6-43C0-8916-DB98E5B5CA89} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} {069365DB-1916-4C38-A90D-5E909BD9EDD0} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} - {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5} = {0FE6558F-2157-47F2-A835-558416CE0E2B} + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} {434119EA-2FFC-4433-9B8E-1E6D94006413} = {0FE6558F-2157-47F2-A835-558416CE0E2B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution