feat: integrate Resend for OTP email delivery and update configuration
This commit is contained in:
@@ -5,4 +5,8 @@ MYSQL_PASSWORD=visione
|
|||||||
MYSQL_ROOT_PASSWORD=root
|
MYSQL_ROOT_PASSWORD=root
|
||||||
MAILPIT_SMTP_PORT=1025
|
MAILPIT_SMTP_PORT=1025
|
||||||
MAILPIT_WEB_PORT=8025
|
MAILPIT_WEB_PORT=8025
|
||||||
|
MAIL_PROVIDER=resend
|
||||||
|
RESEND_API_BASE_URL=https://api.resend.com
|
||||||
|
RESEND_API_KEY=
|
||||||
|
MAIL_FROM=[email protected]
|
||||||
CORS_ALLOWED_ORIGIN=http://localhost:3000
|
CORS_ALLOWED_ORIGIN=http://localhost:3000
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ has been built.
|
|||||||
Local seeded admin:
|
Local seeded admin:
|
||||||
|
|
||||||
- email: `[email protected]`
|
- email: `[email protected]`
|
||||||
- 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
|
If you already had a MySQL volume from the old scaffold, create the local
|
||||||
database/user once:
|
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
|
The app serves the generated JSON at `/api/v1/openapi.json` and Swagger UI at
|
||||||
`/api/v1/docs`.
|
`/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=[email protected]
|
||||||
|
```
|
||||||
|
|
||||||
|
Mailpit can still run locally for manual inspection or future integrations, but
|
||||||
|
current backend OTP flow no longer speaks SMTP directly.
|
||||||
|
|
||||||
## Visione Lab
|
## Visione Lab
|
||||||
|
|
||||||
`scripts/lab/` is a small Streamlit frontend used only to test the backend
|
`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`.
|
domain in Dokploy against service `backend` and container port `5555`.
|
||||||
|
|
||||||
The runtime image includes `config.json`; override environment-specific values
|
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
|
multi-architecture Mailpit image to avoid `exec format error` failures on ARM
|
||||||
servers.
|
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
|
`CORS_ALLOWED_ORIGIN` in Dokploy environment variables. Use the exact browser
|
||||||
origin without quotes or a trailing slash, for example
|
origin without quotes or a trailing slash, for example
|
||||||
`https://visione.studentindipendenti.it`.
|
`https://visione.studentindipendenti.it`.
|
||||||
|
|||||||
+44
-89
@@ -2,13 +2,7 @@
|
|||||||
|
|
||||||
#include <drogon/drogon.h>
|
#include <drogon/drogon.h>
|
||||||
|
|
||||||
#include <array>
|
#include <json/json.h>
|
||||||
#include <cstring>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
#include <netdb.h>
|
|
||||||
#include <sys/socket.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
|
|
||||||
namespace visione::email
|
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"];
|
const auto &mail = drogon::app().getCustomConfig()["mail"];
|
||||||
return mail.get(key, fallback).asString();
|
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<char, 512> 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<ssize_t>(payload.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
bool sendCommand(int socket, const std::string &line)
|
|
||||||
{
|
|
||||||
return sendLine(socket, line) && readResponse(socket);
|
|
||||||
}
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
std::string EmailService::deliveryName() const
|
||||||
|
{
|
||||||
|
return configString("provider", "resend");
|
||||||
|
}
|
||||||
|
|
||||||
bool EmailService::sendOtp(const std::string &to, const std::string &code) const
|
bool EmailService::sendOtp(const std::string &to, const std::string &code) const
|
||||||
{
|
{
|
||||||
const auto host = configString("smtp_host", "127.0.0.1");
|
const auto apiBaseUrl = configString("api_base_url", "https://api.resend.com");
|
||||||
const auto port = std::to_string(configInt("smtp_port", 1025));
|
const auto apiKey = configString("api_key", "");
|
||||||
const auto from = configString("from", "[email protected]");
|
const auto from = configString("from", "");
|
||||||
|
|
||||||
addrinfo hints{};
|
if (apiKey.empty() || from.empty())
|
||||||
hints.ai_family = AF_UNSPEC;
|
|
||||||
hints.ai_socktype = SOCK_STREAM;
|
|
||||||
|
|
||||||
addrinfo *addresses = nullptr;
|
|
||||||
if (getaddrinfo(host.c_str(), port.c_str(), &hints, &addresses) != 0)
|
|
||||||
{
|
{
|
||||||
|
LOG_ERROR << "Email delivery configuration missing: api_key or from";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
int smtp = -1;
|
auto client = drogon::HttpClient::newHttpClient(apiBaseUrl);
|
||||||
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);
|
|
||||||
|
|
||||||
if (smtp < 0)
|
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);
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
client->sendRequest(
|
||||||
|
request,
|
||||||
|
[recipient = to](drogon::ReqResult result, const drogon::HttpResponsePtr &response) {
|
||||||
|
if (result == drogon::ReqResult::Ok &&
|
||||||
|
response != nullptr &&
|
||||||
|
response->statusCode() == drogon::k200OK)
|
||||||
{
|
{
|
||||||
return false;
|
LOG_INFO << "OTP email queued via Resend for " << recipient;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto closeSocket = [&smtp]() {
|
const auto body = response != nullptr ? response->getBody() : std::string{};
|
||||||
if (smtp >= 0)
|
LOG_ERROR << "OTP email delivery failed via Resend for "
|
||||||
{
|
<< recipient
|
||||||
close(smtp);
|
<< ", result=" << static_cast<int>(result)
|
||||||
smtp = -1;
|
<< ", status="
|
||||||
}
|
<< (response != nullptr ? std::to_string(response->statusCode())
|
||||||
};
|
: "none")
|
||||||
|
<< ", body=" << body;
|
||||||
|
});
|
||||||
|
|
||||||
if (!readResponse(smtp) || !sendCommand(smtp, "HELO visione.local") ||
|
return true;
|
||||||
!sendCommand(smtp, "MAIL FROM:<" + from + ">") ||
|
|
||||||
!sendCommand(smtp, "RCPT TO:<" + to + ">") ||
|
|
||||||
!sendCommand(smtp, "DATA"))
|
|
||||||
{
|
|
||||||
closeSocket();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ok = sendCommand(smtp, body) && sendCommand(smtp, "QUIT");
|
|
||||||
closeSocket();
|
|
||||||
return ok;
|
|
||||||
}
|
}
|
||||||
} // namespace visione::email
|
} // namespace visione::email
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -8,6 +8,6 @@ class EmailService
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
bool sendOtp(const std::string &to, const std::string &code) const;
|
bool sendOtp(const std::string &to, const std::string &code) const;
|
||||||
|
std::string deliveryName() const;
|
||||||
};
|
};
|
||||||
} // namespace visione::email
|
} // namespace visione::email
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -64,9 +64,10 @@
|
|||||||
"default_frontend_redirect_uri": "http://localhost:3000/auth/cie/callback"
|
"default_frontend_redirect_uri": "http://localhost:3000/auth/cie/callback"
|
||||||
},
|
},
|
||||||
"mail": {
|
"mail": {
|
||||||
"smtp_host": "127.0.0.1",
|
"provider": "resend",
|
||||||
"smtp_port": 1025,
|
"api_base_url": "https://api.resend.com",
|
||||||
"from": "[email protected]"
|
"api_key": "",
|
||||||
|
"from": "[email protected]"
|
||||||
},
|
},
|
||||||
"uploads": {
|
"uploads": {
|
||||||
"public_base_path": "/uploads",
|
"public_base_path": "/uploads",
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ services:
|
|||||||
DB_PASSWORD: ${DB_PASSWORD:-visione}
|
DB_PASSWORD: ${DB_PASSWORD:-visione}
|
||||||
DB_NAME: ${DB_NAME:-visione}
|
DB_NAME: ${DB_NAME:-visione}
|
||||||
DB_TIMEOUT: ${DB_TIMEOUT:-30}
|
DB_TIMEOUT: ${DB_TIMEOUT:-30}
|
||||||
SMTP_HOST: ${SMTP_HOST:-mailpit}
|
MAIL_PROVIDER: ${MAIL_PROVIDER:-resend}
|
||||||
SMTP_PORT: ${SMTP_PORT:-1025}
|
RESEND_API_BASE_URL: ${RESEND_API_BASE_URL:-https://api.resend.com}
|
||||||
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||||
|
MAIL_FROM: ${MAIL_FROM:[email protected]}
|
||||||
CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN:-http://localhost:3000}
|
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_SERVICE_BASE_URL: ${CIE_AUTH_SERVICE_BASE_URL:-http://127.0.0.1:8001}
|
||||||
CIE_AUTH_START_PATH: ${CIE_AUTH_START_PATH:-/visione/start}
|
CIE_AUTH_START_PATH: ${CIE_AUTH_START_PATH:-/visione/start}
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ Authorization: Bearer <token>
|
|||||||
- `POST /auth/register`
|
- `POST /auth/register`
|
||||||
citizen registration without password
|
citizen registration without password
|
||||||
- `POST /auth/otp/request`
|
- `POST /auth/otp/request`
|
||||||
sends OTP through Mailpit
|
queues OTP email through configured mail provider
|
||||||
- `POST /auth/otp/verify`
|
- `POST /auth/otp/verify`
|
||||||
consumes OTP, returns bearer token
|
consumes OTP, returns bearer token
|
||||||
- `POST /auth/cie/start`
|
- `POST /auth/cie/start`
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ generate public read-only intervention links.
|
|||||||
|
|
||||||
## Runtime Model
|
## Runtime Model
|
||||||
- Auth uses bearer tokens from `api_tokens`.
|
- 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:
|
- CIE login uses external Django RP service:
|
||||||
- Visione creates local `state`
|
- Visione creates local `state`
|
||||||
- Django RP completes CIE/OIDC and sends signed identity payload to Visione
|
- Django RP completes CIE/OIDC and sends signed identity payload to Visione
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ public revocable link generated by operator/admin.
|
|||||||
## Current Auth Model
|
## Current Auth Model
|
||||||
- Registration creates `citizen`.
|
- Registration creates `citizen`.
|
||||||
- Login path:
|
- Login path:
|
||||||
- OTP email via Mailpit
|
- OTP email via configured mail provider
|
||||||
- CIE via external `spid-cie-oidc-django` relying party service
|
- CIE via external `spid-cie-oidc-django` relying party service
|
||||||
- Auth token: bearer token from `api_tokens`
|
- Auth token: bearer token from `api_tokens`
|
||||||
- Banned users cannot authenticate.
|
- Banned users cannot authenticate.
|
||||||
|
|||||||
@@ -2582,7 +2582,7 @@
|
|||||||
},
|
},
|
||||||
"/api/v1/auth/otp/request": {
|
"/api/v1/auth/otp/request": {
|
||||||
"post": {
|
"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",
|
"operationId": "AuthController.requestOtp",
|
||||||
"parameters": [ ],
|
"parameters": [ ],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
|
|||||||
@@ -32,10 +32,14 @@ int main()
|
|||||||
if (const char* envTimeout = std::getenv("DB_TIMEOUT"))
|
if (const char* envTimeout = std::getenv("DB_TIMEOUT"))
|
||||||
config["db_clients"][0]["timeout"] = std::stod(envTimeout);
|
config["db_clients"][0]["timeout"] = std::stod(envTimeout);
|
||||||
|
|
||||||
if (const char* envSmtpHost = std::getenv("SMTP_HOST"))
|
if (const char* envMailProvider = std::getenv("MAIL_PROVIDER"))
|
||||||
config["custom_config"]["mail"]["smtp_host"] = envSmtpHost;
|
config["custom_config"]["mail"]["provider"] = envMailProvider;
|
||||||
if (const char* envSmtpPort = std::getenv("SMTP_PORT"))
|
if (const char* envMailApiBaseUrl = std::getenv("RESEND_API_BASE_URL"))
|
||||||
config["custom_config"]["mail"]["smtp_port"] = std::stoi(envSmtpPort);
|
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"))
|
if (const char* envCorsAllowedOrigin = std::getenv("CORS_ALLOWED_ORIGIN"))
|
||||||
config["custom_config"]["cors"]["allowed_origin"] = envCorsAllowedOrigin;
|
config["custom_config"]["cors"]["allowed_origin"] = envCorsAllowedOrigin;
|
||||||
if (const char* envCieServiceBaseUrl = std::getenv("CIE_AUTH_SERVICE_BASE_URL"))
|
if (const char* envCieServiceBaseUrl = std::getenv("CIE_AUTH_SERVICE_BASE_URL"))
|
||||||
|
|||||||
@@ -328,12 +328,12 @@ Json::Value AuthService::requestOtp(const Json::Value &body)
|
|||||||
email::EmailService mail;
|
email::EmailService mail;
|
||||||
if (!mail.sendOtp(user[0]["email"].as<std::string>(), code))
|
if (!mail.sendOtp(user[0]["email"].as<std::string>(), 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");
|
return failure("otp_email_failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
auto response = success();
|
auto response = success();
|
||||||
response["delivery"] = "mailpit";
|
response["delivery"] = mail.deliveryName();
|
||||||
response["expires_in_minutes"] = otpTtlMinutes();
|
response["expires_in_minutes"] = otpTtlMinutes();
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user