From edadc258db36b79f512044e180c1566d5eefbe35 Mon Sep 17 00:00:00 2001 From: gabdevele Date: Thu, 18 Jun 2026 02:36:53 +0200 Subject: [PATCH] feat: integrate Resend for OTP email delivery and update configuration --- .env.example | 4 + README.md | 22 ++++- common/Email.cc | 133 ++++++++++-------------------- common/Email.h | 2 +- config.json | 7 +- docker-compose.prod.yml | 8 +- docs/API_SPEC.md | 2 +- docs/ARCHITECTURE.md | 2 +- docs/LLM_CONTEXT.md | 2 +- docs/openapi/visione.openapi.yaml | 2 +- main.cc | 12 ++- services/AuthService.cc | 4 +- 12 files changed, 91 insertions(+), 109 deletions(-) diff --git a/.env.example b/.env.example index e23b9f9..117630d 100644 --- a/.env.example +++ b/.env.example @@ -5,4 +5,8 @@ MYSQL_PASSWORD=visione MYSQL_ROOT_PASSWORD=root MAILPIT_SMTP_PORT=1025 MAILPIT_WEB_PORT=8025 +MAIL_PROVIDER=resend +RESEND_API_BASE_URL=https://api.resend.com +RESEND_API_KEY= +MAIL_FROM=noreply@visionebari.it CORS_ALLOWED_ORIGIN=http://localhost:3000 diff --git a/README.md b/README.md index 7229e70..9f74a44 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ has been built. Local seeded admin: - email: `admin@visione.gov.it` -- access uses OTP through Mailpit and is for local development only. +- access uses OTP email delivery and needs mail provider config. If you already had a MySQL volume from the old scaffold, create the local database/user once: @@ -93,6 +93,21 @@ python3 -m unittest scripts/test_generate_openapi.py The app serves the generated JSON at `/api/v1/openapi.json` and Swagger UI at `/api/v1/docs`. +## Email Delivery + +OTP delivery uses Resend HTTP API through Drogon's async `HttpClient`. +Set these environment variables before starting backend: + +```bash +export MAIL_PROVIDER=resend +export RESEND_API_BASE_URL=https://api.resend.com +export RESEND_API_KEY=re_xxx +export MAIL_FROM=noreply@visionebari.it +``` + +Mailpit can still run locally for manual inspection or future integrations, but +current backend OTP flow no longer speaks SMTP directly. + ## Visione Lab `scripts/lab/` is a small Streamlit frontend used only to test the backend @@ -137,11 +152,12 @@ backend deployment with MySQL and a local SMTP catcher. Configure the public domain in Dokploy against service `backend` and container port `5555`. The runtime image includes `config.json`; override environment-specific values -with Dokploy environment variables. The SMTP catcher service uses the +with Dokploy environment variables. The Mailpit service uses the multi-architecture Mailpit image to avoid `exec format error` failures on ARM servers. -Set `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `MYSQL_ROOT_PASSWORD` and +Set `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `MYSQL_ROOT_PASSWORD`, +`RESEND_API_KEY`, `MAIL_FROM` and `CORS_ALLOWED_ORIGIN` in Dokploy environment variables. Use the exact browser origin without quotes or a trailing slash, for example `https://visione.studentindipendenti.it`. diff --git a/common/Email.cc b/common/Email.cc index ca42e6f..4488489 100644 --- a/common/Email.cc +++ b/common/Email.cc @@ -2,13 +2,7 @@ #include -#include -#include -#include - -#include -#include -#include +#include namespace visione::email { @@ -19,101 +13,62 @@ std::string configString(const std::string &key, const std::string &fallback) const auto &mail = drogon::app().getCustomConfig()["mail"]; return mail.get(key, fallback).asString(); } - -int configInt(const std::string &key, int fallback) -{ - const auto &mail = drogon::app().getCustomConfig()["mail"]; - return mail.get(key, fallback).asInt(); -} - -bool readResponse(int socket) -{ - std::array buffer{}; - const auto count = recv(socket, buffer.data(), buffer.size() - 1, 0); - return count > 0 && buffer[0] >= '2' && buffer[0] <= '3'; -} - -bool sendLine(int socket, const std::string &line) -{ - const auto payload = line + "\r\n"; - return send(socket, payload.data(), payload.size(), 0) == - static_cast(payload.size()); -} - -bool sendCommand(int socket, const std::string &line) -{ - return sendLine(socket, line) && readResponse(socket); -} } // namespace +std::string EmailService::deliveryName() const +{ + return configString("provider", "resend"); +} + bool EmailService::sendOtp(const std::string &to, const std::string &code) const { - const auto host = configString("smtp_host", "127.0.0.1"); - const auto port = std::to_string(configInt("smtp_port", 1025)); - const auto from = configString("from", "noreply@visione.local"); + const auto apiBaseUrl = configString("api_base_url", "https://api.resend.com"); + const auto apiKey = configString("api_key", ""); + const auto from = configString("from", ""); - addrinfo hints{}; - hints.ai_family = AF_UNSPEC; - hints.ai_socktype = SOCK_STREAM; - - addrinfo *addresses = nullptr; - if (getaddrinfo(host.c_str(), port.c_str(), &hints, &addresses) != 0) + if (apiKey.empty() || from.empty()) { + LOG_ERROR << "Email delivery configuration missing: api_key or from"; return false; } - int smtp = -1; - for (auto *address = addresses; address != nullptr; address = address->ai_next) - { - smtp = socket(address->ai_family, address->ai_socktype, address->ai_protocol); - if (smtp < 0) - { - continue; - } - if (connect(smtp, address->ai_addr, address->ai_addrlen) == 0) - { - break; - } - close(smtp); - smtp = -1; - } - freeaddrinfo(addresses); + auto client = drogon::HttpClient::newHttpClient(apiBaseUrl); - if (smtp < 0) - { - return false; - } + Json::Value payload; + payload["from"] = "Visione <" + from + ">"; + payload["subject"] = "Codice OTP Visione"; + payload["text"] = "Il tuo codice OTP per Visione e': " + code + + "\nScade tra 10 minuti.\n"; + payload["to"] = Json::arrayValue; + payload["to"].append(to); - const auto closeSocket = [&smtp]() { - if (smtp >= 0) - { - close(smtp); - smtp = -1; - } - }; + auto request = drogon::HttpRequest::newHttpJsonRequest(payload); + request->setMethod(drogon::Post); + request->setPath("/emails"); + request->addHeader("Authorization", "Bearer " + apiKey); + request->addHeader("User-Agent", "visione-backend/1.0"); - if (!readResponse(smtp) || !sendCommand(smtp, "HELO visione.local") || - !sendCommand(smtp, "MAIL FROM:<" + from + ">") || - !sendCommand(smtp, "RCPT TO:<" + to + ">") || - !sendCommand(smtp, "DATA")) - { - closeSocket(); - return false; - } + client->sendRequest( + request, + [recipient = to](drogon::ReqResult result, const drogon::HttpResponsePtr &response) { + if (result == drogon::ReqResult::Ok && + response != nullptr && + response->statusCode() == drogon::k200OK) + { + LOG_INFO << "OTP email queued via Resend for " << recipient; + return; + } - const std::string body = - "From: Visione <" + from + ">\r\n" - "To: " + to + "\r\n" - "Subject: Codice OTP Visione\r\n" - "Content-Type: text/plain; charset=UTF-8\r\n" - "\r\n" - "Il tuo codice OTP per Visione e': " + code + "\r\n" - "Scade tra 10 minuti.\r\n" - "."; + const auto body = response != nullptr ? response->getBody() : std::string{}; + LOG_ERROR << "OTP email delivery failed via Resend for " + << recipient + << ", result=" << static_cast(result) + << ", status=" + << (response != nullptr ? std::to_string(response->statusCode()) + : "none") + << ", body=" << body; + }); - const auto ok = sendCommand(smtp, body) && sendCommand(smtp, "QUIT"); - closeSocket(); - return ok; + return true; } } // namespace visione::email - diff --git a/common/Email.h b/common/Email.h index 5ba9d6e..39f7397 100644 --- a/common/Email.h +++ b/common/Email.h @@ -8,6 +8,6 @@ class EmailService { public: bool sendOtp(const std::string &to, const std::string &code) const; + std::string deliveryName() const; }; } // namespace visione::email - diff --git a/config.json b/config.json index d6c1b7b..62f9ace 100644 --- a/config.json +++ b/config.json @@ -64,9 +64,10 @@ "default_frontend_redirect_uri": "http://localhost:3000/auth/cie/callback" }, "mail": { - "smtp_host": "127.0.0.1", - "smtp_port": 1025, - "from": "noreply@visione.local" + "provider": "resend", + "api_base_url": "https://api.resend.com", + "api_key": "", + "from": "noreply@visionebari.it" }, "uploads": { "public_base_path": "/uploads", diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e07d58d..e016e70 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -13,8 +13,10 @@ services: DB_PASSWORD: ${DB_PASSWORD:-visione} DB_NAME: ${DB_NAME:-visione} DB_TIMEOUT: ${DB_TIMEOUT:-30} - SMTP_HOST: ${SMTP_HOST:-mailpit} - SMTP_PORT: ${SMTP_PORT:-1025} + MAIL_PROVIDER: ${MAIL_PROVIDER:-resend} + RESEND_API_BASE_URL: ${RESEND_API_BASE_URL:-https://api.resend.com} + RESEND_API_KEY: ${RESEND_API_KEY:-} + MAIL_FROM: ${MAIL_FROM:-noreply@visionebari.it} CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN:-http://localhost:3000} CIE_AUTH_SERVICE_BASE_URL: ${CIE_AUTH_SERVICE_BASE_URL:-http://127.0.0.1:8001} CIE_AUTH_START_PATH: ${CIE_AUTH_START_PATH:-/visione/start} @@ -65,4 +67,4 @@ services: volumes: mysql-data: - uploads-data: \ No newline at end of file + uploads-data: diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md index d19c355..01c1032 100644 --- a/docs/API_SPEC.md +++ b/docs/API_SPEC.md @@ -32,7 +32,7 @@ Authorization: Bearer - `POST /auth/register` citizen registration without password - `POST /auth/otp/request` - sends OTP through Mailpit + queues OTP email through configured mail provider - `POST /auth/otp/verify` consumes OTP, returns bearer token - `POST /auth/cie/start` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7ea67c6..2c3127d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -24,7 +24,7 @@ generate public read-only intervention links. ## Runtime Model - Auth uses bearer tokens from `api_tokens`. -- OTP login uses `otp_challenges` and Mailpit in local dev. +- OTP login uses `otp_challenges` and async email delivery through configured mail provider. - CIE login uses external Django RP service: - Visione creates local `state` - Django RP completes CIE/OIDC and sends signed identity payload to Visione diff --git a/docs/LLM_CONTEXT.md b/docs/LLM_CONTEXT.md index b31f2a7..563096b 100644 --- a/docs/LLM_CONTEXT.md +++ b/docs/LLM_CONTEXT.md @@ -30,7 +30,7 @@ public revocable link generated by operator/admin. ## Current Auth Model - Registration creates `citizen`. - Login path: - - OTP email via Mailpit + - OTP email via configured mail provider - CIE via external `spid-cie-oidc-django` relying party service - Auth token: bearer token from `api_tokens` - Banned users cannot authenticate. diff --git a/docs/openapi/visione.openapi.yaml b/docs/openapi/visione.openapi.yaml index 533f487..a37b64e 100644 --- a/docs/openapi/visione.openapi.yaml +++ b/docs/openapi/visione.openapi.yaml @@ -2582,7 +2582,7 @@ }, "/api/v1/auth/otp/request": { "post": { - "description": "Sends a one-time code by email through Mailpit locally.", + "description": "Queues a one-time code by email through configured mail provider.", "operationId": "AuthController.requestOtp", "parameters": [ ], "requestBody": { diff --git a/main.cc b/main.cc index 045e62d..b105697 100644 --- a/main.cc +++ b/main.cc @@ -32,10 +32,14 @@ int main() if (const char* envTimeout = std::getenv("DB_TIMEOUT")) config["db_clients"][0]["timeout"] = std::stod(envTimeout); - if (const char* envSmtpHost = std::getenv("SMTP_HOST")) - config["custom_config"]["mail"]["smtp_host"] = envSmtpHost; - if (const char* envSmtpPort = std::getenv("SMTP_PORT")) - config["custom_config"]["mail"]["smtp_port"] = std::stoi(envSmtpPort); + if (const char* envMailProvider = std::getenv("MAIL_PROVIDER")) + config["custom_config"]["mail"]["provider"] = envMailProvider; + if (const char* envMailApiBaseUrl = std::getenv("RESEND_API_BASE_URL")) + config["custom_config"]["mail"]["api_base_url"] = envMailApiBaseUrl; + if (const char* envMailApiKey = std::getenv("RESEND_API_KEY")) + config["custom_config"]["mail"]["api_key"] = envMailApiKey; + if (const char* envMailFrom = std::getenv("MAIL_FROM")) + config["custom_config"]["mail"]["from"] = envMailFrom; if (const char* envCorsAllowedOrigin = std::getenv("CORS_ALLOWED_ORIGIN")) config["custom_config"]["cors"]["allowed_origin"] = envCorsAllowedOrigin; if (const char* envCieServiceBaseUrl = std::getenv("CIE_AUTH_SERVICE_BASE_URL")) diff --git a/services/AuthService.cc b/services/AuthService.cc index b5ec2e7..d374015 100644 --- a/services/AuthService.cc +++ b/services/AuthService.cc @@ -328,12 +328,12 @@ Json::Value AuthService::requestOtp(const Json::Value &body) email::EmailService mail; if (!mail.sendOtp(user[0]["email"].as(), code)) { - LOG_WARN << "Could not send OTP email through Mailpit"; + LOG_WARN << "Could not queue OTP email through " << mail.deliveryName(); return failure("otp_email_failed"); } auto response = success(); - response["delivery"] = "mailpit"; + response["delivery"] = mail.deliveryName(); response["expires_in_minutes"] = otpTtlMinutes(); return response; }