diff --git a/.env.example b/.env.example index 401f60c..732bbe9 100644 --- a/.env.example +++ b/.env.example @@ -16,11 +16,16 @@ # For local development, this can be left unset as 'localhost' is allowed by default. # CORS_ALLOWED_ORIGIN="https://your-dashboard.com" -# REQUIRED FOR PRODUCTION: The issuer URL for JWTs. -# Defaults to 'http://localhost:8080' for local development if not set. -# For production, this MUST be the public URL of your API. -# JWT_ISSUER="https://api.your-domain.com" +# REQUIRED: Your SendGrid API key for sending emails. +# SENDGRID_API_KEY="your-sendgrid-api-key" -# OPTIONAL: The expiry duration for JWTs in hours. -# Defaults to 1 hour if not set. -# JWT_EXPIRY_HOURS="24" +# REQUIRED: The default email address to send emails from. +# DEFAULT_SENDER_EMAIL="noreply@example.com" + +# REQUIRED: The SendGrid template ID for the OTP email. +# OTP_TEMPLATE_ID="d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# OPTIONAL: The base URL for the SendGrid API. +# Defaults to "https://api.sendgrid.com" if not set. +# Use "https://api.eu.sendgrid.com" for EU-based accounts. +# SENDGRID_API_URL="https://api.sendgrid.com" diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index f9bddf9..7b173c9 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -13,8 +13,9 @@ import 'package:ht_api/src/services/user_preference_limit_service.dart'; import 'package:ht_api/src/services/verification_code_storage_service.dart'; import 'package:ht_data_mongodb/ht_data_mongodb.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; -import 'package:ht_email_inmemory/ht_email_inmemory.dart'; import 'package:ht_email_repository/ht_email_repository.dart'; +import 'package:ht_email_sendgrid/ht_email_sendgrid.dart'; +import 'package:ht_http_client/ht_http_client.dart'; import 'package:ht_shared/ht_shared.dart'; import 'package:logging/logging.dart'; @@ -166,9 +167,24 @@ class AppDependencies { ); remoteConfigRepository = HtDataRepository(dataClient: remoteConfigClient); - const emailClient = HtEmailInMemoryClient(); - - emailRepository = const HtEmailRepository(emailClient: emailClient); + // Configure the HTTP client for SendGrid. + // The HtHttpClient's AuthInterceptor will use the tokenProvider to add + // the 'Authorization: Bearer ' header. + final sendGridHttpClient = HtHttpClient( + baseUrl: + EnvironmentConfig.sendGridApiUrl ?? 'https://api.sendgrid.com/v3', + tokenProvider: () async => EnvironmentConfig.sendGridApiKey, + isWeb: false, // This is a server-side implementation. + logger: Logger('HtEmailSendgridClient'), + ); + + // Initialize the SendGrid email client with the dedicated HTTP client. + final emailClient = HtEmailSendGrid( + httpClient: sendGridHttpClient, + log: Logger('HtEmailSendgrid'), + ); + + emailRepository = HtEmailRepository(emailClient: emailClient); // 5. Initialize Services tokenBlacklistService = MongoDbTokenBlacklistService( diff --git a/lib/src/config/environment_config.dart b/lib/src/config/environment_config.dart index 55b37ed..f35f648 100644 --- a/lib/src/config/environment_config.dart +++ b/lib/src/config/environment_config.dart @@ -113,8 +113,7 @@ abstract final class EnvironmentConfig { /// /// The value is read from the `JWT_ISSUER` environment variable. /// Defaults to 'http://localhost:8080' if not set. - static String get jwtIssuer => - _env['JWT_ISSUER'] ?? 'http://localhost:8080'; + static String get jwtIssuer => _env['JWT_ISSUER'] ?? 'http://localhost:8080'; /// Retrieves the JWT expiry duration in hours from the environment. /// @@ -124,4 +123,51 @@ abstract final class EnvironmentConfig { final hours = int.tryParse(_env['JWT_EXPIRY_HOURS'] ?? '1'); return Duration(hours: hours ?? 1); } + + /// Retrieves the SendGrid API key from the environment. + /// + /// Throws a [StateError] if the `SENDGRID_API_KEY` is not set. + static String get sendGridApiKey { + final apiKey = _env['SENDGRID_API_KEY']; + if (apiKey == null || apiKey.isEmpty) { + _log.severe('SENDGRID_API_KEY not found in environment variables.'); + throw StateError( + 'FATAL: SENDGRID_API_KEY environment variable is not set.', + ); + } + return apiKey; + } + + /// Retrieves the default sender email from the environment. + /// + /// Throws a [StateError] if the `DEFAULT_SENDER_EMAIL` is not set. + static String get defaultSenderEmail { + final email = _env['DEFAULT_SENDER_EMAIL']; + if (email == null || email.isEmpty) { + _log.severe('DEFAULT_SENDER_EMAIL not found in environment variables.'); + throw StateError( + 'FATAL: DEFAULT_SENDER_EMAIL environment variable is not set.', + ); + } + return email; + } + + /// Retrieves the SendGrid OTP template ID from the environment. + /// + /// Throws a [StateError] if the `OTP_TEMPLATE_ID` is not set. + static String get otpTemplateId { + final templateId = _env['OTP_TEMPLATE_ID']; + if (templateId == null || templateId.isEmpty) { + _log.severe('OTP_TEMPLATE_ID not found in environment variables.'); + throw StateError( + 'FATAL: OTP_TEMPLATE_ID environment variable is not set.', + ); + } + return templateId; + } + + /// Retrieves the SendGrid API URL from the environment, if provided. + /// + /// Returns `null` if the `SENDGRID_API_URL` is not set. + static String? get sendGridApiUrl => _env['SENDGRID_API_URL']; } diff --git a/lib/src/middlewares/authentication_middleware.dart b/lib/src/middlewares/authentication_middleware.dart index d2715a3..6656b81 100644 --- a/lib/src/middlewares/authentication_middleware.dart +++ b/lib/src/middlewares/authentication_middleware.dart @@ -47,9 +47,7 @@ Middleware authenticationProvider() { _log.finer('Attempting to validate token...'); // Validate the token using the service user = await tokenService.validateToken(token); - _log.finer( - 'Token validation returned: ${user?.id ?? 'null'}', - ); + _log.finer('Token validation returned: ${user?.id ?? 'null'}'); if (user != null) { _log.info('Authentication successful for user: ${user.id}'); } else { @@ -68,11 +66,7 @@ Middleware authenticationProvider() { user = null; // Keep user null if HtHttpException occurred } catch (e, s) { // Catch unexpected errors during validation - _log.severe( - 'Unexpected error during token validation.', - e, - s, - ); + _log.severe('Unexpected error during token validation.', e, s); user = null; // Keep user null if unexpected error occurred } } else { @@ -81,9 +75,7 @@ Middleware authenticationProvider() { // Provide the User object (or null) into the context // This makes `context.read()` available downstream. - _log.finer( - 'Providing User (${user?.id ?? 'null'}) to context.', - ); + _log.finer('Providing User (${user?.id ?? 'null'}) to context.'); return handler(context.provide(() => user)); }; }; diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index d46aa74..575cf67 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -1,5 +1,8 @@ +// ignore_for_file: inference_failure_on_untyped_parameter, comment_references + import 'dart:async'; +import 'package:ht_api/src/config/environment_config.dart'; import 'package:ht_api/src/rbac/permission_service.dart'; import 'package:ht_api/src/rbac/permissions.dart'; import 'package:ht_api/src/services/auth_token_service.dart'; @@ -102,7 +105,12 @@ class AuthService { .generateAndStoreSignInCode(email); // Send the code via email - await _emailRepository.sendOtpEmail(recipientEmail: email, otpCode: code); + await _emailRepository.sendOtpEmail( + senderEmail: EnvironmentConfig.defaultSenderEmail, + recipientEmail: email, + templateId: EnvironmentConfig.otpTemplateId, + otpCode: code, + ); _log.info('Initiated email sign-in for $email, code sent.'); } on HtHttpException { // Propagate known exceptions from dependencies or from this method's logic. @@ -148,10 +156,12 @@ class AuthService { String? currentToken, }) async { // 1. Validate the verification code. - final isValidCode = - await _verificationCodeStorageService.validateSignInCode(email, code); + final isValidCode = await _verificationCodeStorageService + .validateSignInCode(email, code); if (!isValidCode) { - throw const InvalidInputException('Invalid or expired verification code.'); + throw const InvalidInputException( + 'Invalid or expired verification code.', + ); } // After successful validation, clear the code from storage. diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 7a294e3..fb58c84 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -128,22 +128,19 @@ class DatabaseSeedingService { _log.info('Ensuring database indexes exist...'); try { // Text index for searching headlines by title - await _db.collection('headlines').createIndex( - keys: {'title': 'text'}, - name: 'headlines_text_index', - ); + await _db + .collection('headlines') + .createIndex(keys: {'title': 'text'}, name: 'headlines_text_index'); // Text index for searching topics by name - await _db.collection('topics').createIndex( - keys: {'name': 'text'}, - name: 'topics_text_index', - ); + await _db + .collection('topics') + .createIndex(keys: {'name': 'text'}, name: 'topics_text_index'); // Text index for searching sources by name - await _db.collection('sources').createIndex( - keys: {'name': 'text'}, - name: 'sources_text_index', - ); + await _db + .collection('sources') + .createIndex(keys: {'name': 'text'}, name: 'sources_text_index'); // --- TTL and Unique Indexes via runCommand --- // The following indexes are created using the generic `runCommand` because @@ -169,8 +166,8 @@ class DatabaseSeedingService { 'key': {'email': 1}, 'name': 'email_unique_index', 'unique': true, - } - ] + }, + ], }); // Index for the token blacklist collection @@ -183,8 +180,8 @@ class DatabaseSeedingService { 'key': {'expiry': 1}, 'name': 'expiry_ttl_index', 'expireAfterSeconds': 0, - } - ] + }, + ], }); _log.info('Database indexes are set up correctly.'); diff --git a/lib/src/services/mongodb_token_blacklist_service.dart b/lib/src/services/mongodb_token_blacklist_service.dart index 3974b7b..a975636 100644 --- a/lib/src/services/mongodb_token_blacklist_service.dart +++ b/lib/src/services/mongodb_token_blacklist_service.dart @@ -21,8 +21,8 @@ class MongoDbTokenBlacklistService implements TokenBlacklistService { MongoDbTokenBlacklistService({ required MongoDbConnectionManager connectionManager, required Logger log, - }) : _connectionManager = connectionManager, - _log = log; + }) : _connectionManager = connectionManager, + _log = log; final MongoDbConnectionManager _connectionManager; final Logger _log; @@ -35,19 +35,14 @@ class MongoDbTokenBlacklistService implements TokenBlacklistService { try { // The document structure is simple: the JTI is the primary key (_id) // and `expiry` is the TTL-indexed field. - await _collection.insertOne({ - '_id': jti, - 'expiry': expiry, - }); + await _collection.insertOne({'_id': jti, 'expiry': expiry}); _log.info('Blacklisted jti: $jti (expires: $expiry)'); } on MongoDartError catch (e) { // Handle the specific case of a duplicate key error, which means the // token is already blacklisted. This is not a failure condition. // We check the message because the error type may not be specific enough. if (e.message.contains('duplicate key')) { - _log.warning( - 'Attempted to blacklist an already blacklisted jti: $jti', - ); + _log.warning('Attempted to blacklist an already blacklisted jti: $jti'); // Swallow the exception as the desired state is already achieved. return; } diff --git a/lib/src/services/mongodb_verification_code_storage_service.dart b/lib/src/services/mongodb_verification_code_storage_service.dart index e4436f5..59d1d9f 100644 --- a/lib/src/services/mongodb_verification_code_storage_service.dart +++ b/lib/src/services/mongodb_verification_code_storage_service.dart @@ -24,8 +24,8 @@ class MongoDbVerificationCodeStorageService required MongoDbConnectionManager connectionManager, required Logger log, this.codeExpiryDuration = const Duration(minutes: 15), - }) : _connectionManager = connectionManager, - _log = log; + }) : _connectionManager = connectionManager, + _log = log; final MongoDbConnectionManager _connectionManager; final Logger _log; @@ -61,9 +61,7 @@ class MongoDbVerificationCodeStorageService .setOnInsert('_id', ObjectId()), upsert: true, ); - _log.info( - 'Stored sign-in code for $email (expires: $expiresAt)', - ); + _log.info('Stored sign-in code for $email (expires: $expiresAt)'); return code; } catch (e) { _log.severe('Failed to store sign-in code for $email: $e'); diff --git a/lib/src/services/verification_code_storage_service.dart b/lib/src/services/verification_code_storage_service.dart index a832009..dc64635 100644 --- a/lib/src/services/verification_code_storage_service.dart +++ b/lib/src/services/verification_code_storage_service.dart @@ -1,36 +1,8 @@ // ignore_for_file: library_private_types_in_public_api import 'dart:async'; -import 'dart:math'; import 'package:ht_shared/ht_shared.dart'; -import 'package:meta/meta.dart'; - -// Default duration for code expiry (e.g., 15 minutes) -const _defaultCodeExpiryDuration = Duration(minutes: 15); -// Default interval for cleaning up expired codes (e.g., 1 hour) -const _defaultCleanupInterval = Duration(hours: 1); - -/// {@template code_entry_base} -/// Base class for storing verification code entries. -/// {@endtemplate} -class _CodeEntryBase { - /// {@macro code_entry_base} - _CodeEntryBase(this.code, this.expiresAt); - - final String code; - final DateTime expiresAt; - - bool get isExpired => DateTime.now().isAfter(expiresAt); -} - -/// {@template sign_in_code_entry} -/// Stores a verification code for standard email sign-in. -/// {@endtemplate} -class _SignInCodeEntry extends _CodeEntryBase { - /// {@macro sign_in_code_entry} - _SignInCodeEntry(super.code, super.expiresAt); -} /// {@template verification_code_storage_service} /// Defines the interface for a service that manages verification codes diff --git a/pubspec.yaml b/pubspec.yaml index b4fe4dd..4d78953 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,12 +22,12 @@ dependencies: ht_email_client: git: url: https://github.com/headlines-toolkit/ht-email-client.git - ht_email_inmemory: - git: - url: https://github.com/headlines-toolkit/ht-email-inmemory.git ht_email_repository: git: url: https://github.com/headlines-toolkit/ht-email-repository.git + ht_email_sendgrid: + git: + url: https://github.com/headlines-toolkit/ht-email-sendgrid.git ht_http_client: git: url: https://github.com/headlines-toolkit/ht-http-client.git diff --git a/routes/api/v1/data/[id]/index.dart b/routes/api/v1/data/[id]/index.dart index 05ada7c..0ce197f 100644 --- a/routes/api/v1/data/[id]/index.dart +++ b/routes/api/v1/data/[id]/index.dart @@ -467,15 +467,15 @@ Future _handleDelete( final repo = context.read>(); itemToDelete = await repo.read( id: id, - userId: userIdForRepoCall, - ); // userId should be null for AppConfig - default: - _logger.severe( - 'Unsupported model type "$modelName" reached _handleDelete ownership check.', - ); - // Throw an exception to be caught by the errorHandler - throw OperationFailedException( - 'Unsupported model type "$modelName" reached handler.', + userId: userIdForRepoCall, + ); // userId should be null for AppConfig + default: + _logger.severe( + 'Unsupported model type "$modelName" reached _handleDelete ownership check.', + ); + // Throw an exception to be caught by the errorHandler + throw OperationFailedException( + 'Unsupported model type "$modelName" reached handler.', ); }