Skip to content

Commit 910c1c8

Browse files
authored
Merge pull request #26 from headlines-toolkit/refactor_use_sendgrid_for_email_transactions
Refactor use sendgrid for email transactions
2 parents 94305fd + 6b8114e commit 910c1c8

11 files changed

+129
-98
lines changed

.env.example

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,16 @@
1616
# For local development, this can be left unset as 'localhost' is allowed by default.
1717
# CORS_ALLOWED_ORIGIN="https://your-dashboard.com"
1818

19-
# REQUIRED FOR PRODUCTION: The issuer URL for JWTs.
20-
# Defaults to 'http://localhost:8080' for local development if not set.
21-
# For production, this MUST be the public URL of your API.
22-
# JWT_ISSUER="https://api.your-domain.com"
19+
# REQUIRED: Your SendGrid API key for sending emails.
20+
# SENDGRID_API_KEY="your-sendgrid-api-key"
2321

24-
# OPTIONAL: The expiry duration for JWTs in hours.
25-
# Defaults to 1 hour if not set.
26-
# JWT_EXPIRY_HOURS="24"
22+
# REQUIRED: The default email address to send emails from.
23+
# DEFAULT_SENDER_EMAIL="[email protected]"
24+
25+
# REQUIRED: The SendGrid template ID for the OTP email.
26+
# OTP_TEMPLATE_ID="d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
27+
28+
# OPTIONAL: The base URL for the SendGrid API.
29+
# Defaults to "https://api.sendgrid.com" if not set.
30+
# Use "https://api.eu.sendgrid.com" for EU-based accounts.
31+
# SENDGRID_API_URL="https://api.sendgrid.com"

lib/src/config/app_dependencies.dart

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import 'package:ht_api/src/services/user_preference_limit_service.dart';
1313
import 'package:ht_api/src/services/verification_code_storage_service.dart';
1414
import 'package:ht_data_mongodb/ht_data_mongodb.dart';
1515
import 'package:ht_data_repository/ht_data_repository.dart';
16-
import 'package:ht_email_inmemory/ht_email_inmemory.dart';
1716
import 'package:ht_email_repository/ht_email_repository.dart';
17+
import 'package:ht_email_sendgrid/ht_email_sendgrid.dart';
18+
import 'package:ht_http_client/ht_http_client.dart';
1819
import 'package:ht_shared/ht_shared.dart';
1920
import 'package:logging/logging.dart';
2021

@@ -166,9 +167,24 @@ class AppDependencies {
166167
);
167168
remoteConfigRepository = HtDataRepository(dataClient: remoteConfigClient);
168169

169-
const emailClient = HtEmailInMemoryClient();
170-
171-
emailRepository = const HtEmailRepository(emailClient: emailClient);
170+
// Configure the HTTP client for SendGrid.
171+
// The HtHttpClient's AuthInterceptor will use the tokenProvider to add
172+
// the 'Authorization: Bearer <SENDGRID_API_KEY>' header.
173+
final sendGridHttpClient = HtHttpClient(
174+
baseUrl:
175+
EnvironmentConfig.sendGridApiUrl ?? 'https://api.sendgrid.com/v3',
176+
tokenProvider: () async => EnvironmentConfig.sendGridApiKey,
177+
isWeb: false, // This is a server-side implementation.
178+
logger: Logger('HtEmailSendgridClient'),
179+
);
180+
181+
// Initialize the SendGrid email client with the dedicated HTTP client.
182+
final emailClient = HtEmailSendGrid(
183+
httpClient: sendGridHttpClient,
184+
log: Logger('HtEmailSendgrid'),
185+
);
186+
187+
emailRepository = HtEmailRepository(emailClient: emailClient);
172188

173189
// 5. Initialize Services
174190
tokenBlacklistService = MongoDbTokenBlacklistService(

lib/src/config/environment_config.dart

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,7 @@ abstract final class EnvironmentConfig {
113113
///
114114
/// The value is read from the `JWT_ISSUER` environment variable.
115115
/// Defaults to 'http://localhost:8080' if not set.
116-
static String get jwtIssuer =>
117-
_env['JWT_ISSUER'] ?? 'http://localhost:8080';
116+
static String get jwtIssuer => _env['JWT_ISSUER'] ?? 'http://localhost:8080';
118117

119118
/// Retrieves the JWT expiry duration in hours from the environment.
120119
///
@@ -124,4 +123,51 @@ abstract final class EnvironmentConfig {
124123
final hours = int.tryParse(_env['JWT_EXPIRY_HOURS'] ?? '1');
125124
return Duration(hours: hours ?? 1);
126125
}
126+
127+
/// Retrieves the SendGrid API key from the environment.
128+
///
129+
/// Throws a [StateError] if the `SENDGRID_API_KEY` is not set.
130+
static String get sendGridApiKey {
131+
final apiKey = _env['SENDGRID_API_KEY'];
132+
if (apiKey == null || apiKey.isEmpty) {
133+
_log.severe('SENDGRID_API_KEY not found in environment variables.');
134+
throw StateError(
135+
'FATAL: SENDGRID_API_KEY environment variable is not set.',
136+
);
137+
}
138+
return apiKey;
139+
}
140+
141+
/// Retrieves the default sender email from the environment.
142+
///
143+
/// Throws a [StateError] if the `DEFAULT_SENDER_EMAIL` is not set.
144+
static String get defaultSenderEmail {
145+
final email = _env['DEFAULT_SENDER_EMAIL'];
146+
if (email == null || email.isEmpty) {
147+
_log.severe('DEFAULT_SENDER_EMAIL not found in environment variables.');
148+
throw StateError(
149+
'FATAL: DEFAULT_SENDER_EMAIL environment variable is not set.',
150+
);
151+
}
152+
return email;
153+
}
154+
155+
/// Retrieves the SendGrid OTP template ID from the environment.
156+
///
157+
/// Throws a [StateError] if the `OTP_TEMPLATE_ID` is not set.
158+
static String get otpTemplateId {
159+
final templateId = _env['OTP_TEMPLATE_ID'];
160+
if (templateId == null || templateId.isEmpty) {
161+
_log.severe('OTP_TEMPLATE_ID not found in environment variables.');
162+
throw StateError(
163+
'FATAL: OTP_TEMPLATE_ID environment variable is not set.',
164+
);
165+
}
166+
return templateId;
167+
}
168+
169+
/// Retrieves the SendGrid API URL from the environment, if provided.
170+
///
171+
/// Returns `null` if the `SENDGRID_API_URL` is not set.
172+
static String? get sendGridApiUrl => _env['SENDGRID_API_URL'];
127173
}

lib/src/middlewares/authentication_middleware.dart

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,7 @@ Middleware authenticationProvider() {
4747
_log.finer('Attempting to validate token...');
4848
// Validate the token using the service
4949
user = await tokenService.validateToken(token);
50-
_log.finer(
51-
'Token validation returned: ${user?.id ?? 'null'}',
52-
);
50+
_log.finer('Token validation returned: ${user?.id ?? 'null'}');
5351
if (user != null) {
5452
_log.info('Authentication successful for user: ${user.id}');
5553
} else {
@@ -68,11 +66,7 @@ Middleware authenticationProvider() {
6866
user = null; // Keep user null if HtHttpException occurred
6967
} catch (e, s) {
7068
// Catch unexpected errors during validation
71-
_log.severe(
72-
'Unexpected error during token validation.',
73-
e,
74-
s,
75-
);
69+
_log.severe('Unexpected error during token validation.', e, s);
7670
user = null; // Keep user null if unexpected error occurred
7771
}
7872
} else {
@@ -81,9 +75,7 @@ Middleware authenticationProvider() {
8175

8276
// Provide the User object (or null) into the context
8377
// This makes `context.read<User?>()` available downstream.
84-
_log.finer(
85-
'Providing User (${user?.id ?? 'null'}) to context.',
86-
);
78+
_log.finer('Providing User (${user?.id ?? 'null'}) to context.');
8779
return handler(context.provide<User?>(() => user));
8880
};
8981
};

lib/src/services/auth_service.dart

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
// ignore_for_file: inference_failure_on_untyped_parameter, comment_references
2+
13
import 'dart:async';
24

5+
import 'package:ht_api/src/config/environment_config.dart';
36
import 'package:ht_api/src/rbac/permission_service.dart';
47
import 'package:ht_api/src/rbac/permissions.dart';
58
import 'package:ht_api/src/services/auth_token_service.dart';
@@ -102,7 +105,12 @@ class AuthService {
102105
.generateAndStoreSignInCode(email);
103106

104107
// Send the code via email
105-
await _emailRepository.sendOtpEmail(recipientEmail: email, otpCode: code);
108+
await _emailRepository.sendOtpEmail(
109+
senderEmail: EnvironmentConfig.defaultSenderEmail,
110+
recipientEmail: email,
111+
templateId: EnvironmentConfig.otpTemplateId,
112+
otpCode: code,
113+
);
106114
_log.info('Initiated email sign-in for $email, code sent.');
107115
} on HtHttpException {
108116
// Propagate known exceptions from dependencies or from this method's logic.
@@ -148,10 +156,12 @@ class AuthService {
148156
String? currentToken,
149157
}) async {
150158
// 1. Validate the verification code.
151-
final isValidCode =
152-
await _verificationCodeStorageService.validateSignInCode(email, code);
159+
final isValidCode = await _verificationCodeStorageService
160+
.validateSignInCode(email, code);
153161
if (!isValidCode) {
154-
throw const InvalidInputException('Invalid or expired verification code.');
162+
throw const InvalidInputException(
163+
'Invalid or expired verification code.',
164+
);
155165
}
156166

157167
// After successful validation, clear the code from storage.

lib/src/services/database_seeding_service.dart

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -128,22 +128,19 @@ class DatabaseSeedingService {
128128
_log.info('Ensuring database indexes exist...');
129129
try {
130130
// Text index for searching headlines by title
131-
await _db.collection('headlines').createIndex(
132-
keys: {'title': 'text'},
133-
name: 'headlines_text_index',
134-
);
131+
await _db
132+
.collection('headlines')
133+
.createIndex(keys: {'title': 'text'}, name: 'headlines_text_index');
135134

136135
// Text index for searching topics by name
137-
await _db.collection('topics').createIndex(
138-
keys: {'name': 'text'},
139-
name: 'topics_text_index',
140-
);
136+
await _db
137+
.collection('topics')
138+
.createIndex(keys: {'name': 'text'}, name: 'topics_text_index');
141139

142140
// Text index for searching sources by name
143-
await _db.collection('sources').createIndex(
144-
keys: {'name': 'text'},
145-
name: 'sources_text_index',
146-
);
141+
await _db
142+
.collection('sources')
143+
.createIndex(keys: {'name': 'text'}, name: 'sources_text_index');
147144

148145
// --- TTL and Unique Indexes via runCommand ---
149146
// The following indexes are created using the generic `runCommand` because
@@ -169,8 +166,8 @@ class DatabaseSeedingService {
169166
'key': {'email': 1},
170167
'name': 'email_unique_index',
171168
'unique': true,
172-
}
173-
]
169+
},
170+
],
174171
});
175172

176173
// Index for the token blacklist collection
@@ -183,8 +180,8 @@ class DatabaseSeedingService {
183180
'key': {'expiry': 1},
184181
'name': 'expiry_ttl_index',
185182
'expireAfterSeconds': 0,
186-
}
187-
]
183+
},
184+
],
188185
});
189186

190187
_log.info('Database indexes are set up correctly.');

lib/src/services/mongodb_token_blacklist_service.dart

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ class MongoDbTokenBlacklistService implements TokenBlacklistService {
2121
MongoDbTokenBlacklistService({
2222
required MongoDbConnectionManager connectionManager,
2323
required Logger log,
24-
}) : _connectionManager = connectionManager,
25-
_log = log;
24+
}) : _connectionManager = connectionManager,
25+
_log = log;
2626

2727
final MongoDbConnectionManager _connectionManager;
2828
final Logger _log;
@@ -35,19 +35,14 @@ class MongoDbTokenBlacklistService implements TokenBlacklistService {
3535
try {
3636
// The document structure is simple: the JTI is the primary key (_id)
3737
// and `expiry` is the TTL-indexed field.
38-
await _collection.insertOne({
39-
'_id': jti,
40-
'expiry': expiry,
41-
});
38+
await _collection.insertOne({'_id': jti, 'expiry': expiry});
4239
_log.info('Blacklisted jti: $jti (expires: $expiry)');
4340
} on MongoDartError catch (e) {
4441
// Handle the specific case of a duplicate key error, which means the
4542
// token is already blacklisted. This is not a failure condition.
4643
// We check the message because the error type may not be specific enough.
4744
if (e.message.contains('duplicate key')) {
48-
_log.warning(
49-
'Attempted to blacklist an already blacklisted jti: $jti',
50-
);
45+
_log.warning('Attempted to blacklist an already blacklisted jti: $jti');
5146
// Swallow the exception as the desired state is already achieved.
5247
return;
5348
}

lib/src/services/mongodb_verification_code_storage_service.dart

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ class MongoDbVerificationCodeStorageService
2424
required MongoDbConnectionManager connectionManager,
2525
required Logger log,
2626
this.codeExpiryDuration = const Duration(minutes: 15),
27-
}) : _connectionManager = connectionManager,
28-
_log = log;
27+
}) : _connectionManager = connectionManager,
28+
_log = log;
2929

3030
final MongoDbConnectionManager _connectionManager;
3131
final Logger _log;
@@ -61,9 +61,7 @@ class MongoDbVerificationCodeStorageService
6161
.setOnInsert('_id', ObjectId()),
6262
upsert: true,
6363
);
64-
_log.info(
65-
'Stored sign-in code for $email (expires: $expiresAt)',
66-
);
64+
_log.info('Stored sign-in code for $email (expires: $expiresAt)');
6765
return code;
6866
} catch (e) {
6967
_log.severe('Failed to store sign-in code for $email: $e');

lib/src/services/verification_code_storage_service.dart

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,8 @@
11
// ignore_for_file: library_private_types_in_public_api
22

33
import 'dart:async';
4-
import 'dart:math';
54

65
import 'package:ht_shared/ht_shared.dart';
7-
import 'package:meta/meta.dart';
8-
9-
// Default duration for code expiry (e.g., 15 minutes)
10-
const _defaultCodeExpiryDuration = Duration(minutes: 15);
11-
// Default interval for cleaning up expired codes (e.g., 1 hour)
12-
const _defaultCleanupInterval = Duration(hours: 1);
13-
14-
/// {@template code_entry_base}
15-
/// Base class for storing verification code entries.
16-
/// {@endtemplate}
17-
class _CodeEntryBase {
18-
/// {@macro code_entry_base}
19-
_CodeEntryBase(this.code, this.expiresAt);
20-
21-
final String code;
22-
final DateTime expiresAt;
23-
24-
bool get isExpired => DateTime.now().isAfter(expiresAt);
25-
}
26-
27-
/// {@template sign_in_code_entry}
28-
/// Stores a verification code for standard email sign-in.
29-
/// {@endtemplate}
30-
class _SignInCodeEntry extends _CodeEntryBase {
31-
/// {@macro sign_in_code_entry}
32-
_SignInCodeEntry(super.code, super.expiresAt);
33-
}
346

357
/// {@template verification_code_storage_service}
368
/// Defines the interface for a service that manages verification codes

pubspec.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@ dependencies:
2222
ht_email_client:
2323
git:
2424
url: https://github.com/headlines-toolkit/ht-email-client.git
25-
ht_email_inmemory:
26-
git:
27-
url: https://github.com/headlines-toolkit/ht-email-inmemory.git
2825
ht_email_repository:
2926
git:
3027
url: https://github.com/headlines-toolkit/ht-email-repository.git
28+
ht_email_sendgrid:
29+
git:
30+
url: https://github.com/headlines-toolkit/ht-email-sendgrid.git
3131
ht_http_client:
3232
git:
3333
url: https://github.com/headlines-toolkit/ht-http-client.git

0 commit comments

Comments
 (0)