auth-kit-fastapi 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,468 @@
1
+ """
2
+ Email service for authentication
3
+ """
4
+
5
+ from typing import Optional, Dict, Any
6
+ from datetime import datetime
7
+ import logging
8
+ from email.mime.text import MIMEText
9
+ from email.mime.multipart import MIMEMultipart
10
+ import smtplib
11
+ import ssl
12
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
13
+
14
+ from ..core.security import (
15
+ generate_email_verification_token,
16
+ generate_password_reset_token
17
+ )
18
+ from ..core.events import auth_events
19
+ from ..config import AuthConfig
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class EmailService:
25
+ """
26
+ Service for sending authentication-related emails
27
+
28
+ Supports:
29
+ - Email verification
30
+ - Password reset
31
+ - 2FA notifications
32
+ - Security alerts
33
+ """
34
+
35
+ def __init__(self, config: AuthConfig):
36
+ """
37
+ Initialize email service
38
+
39
+ Args:
40
+ config: Authentication configuration
41
+ """
42
+ self.config = config
43
+
44
+ # Initialize template environment if templates are available
45
+ if config.email_template_dir:
46
+ self.template_env = Environment(
47
+ loader=FileSystemLoader(config.email_template_dir),
48
+ autoescape=select_autoescape(['html', 'xml'])
49
+ )
50
+ else:
51
+ self.template_env = None
52
+
53
+ def _create_smtp_connection(self) -> smtplib.SMTP:
54
+ """
55
+ Create SMTP connection
56
+
57
+ Returns:
58
+ SMTP connection
59
+ """
60
+ # Create SSL context
61
+ context = ssl.create_default_context()
62
+
63
+ # Create connection based on SSL usage
64
+ if self.config.smtp_ssl:
65
+ smtp = smtplib.SMTP_SSL(
66
+ self.config.smtp_host,
67
+ self.config.smtp_port,
68
+ context=context
69
+ )
70
+ else:
71
+ smtp = smtplib.SMTP(
72
+ self.config.smtp_host,
73
+ self.config.smtp_port
74
+ )
75
+ if self.config.smtp_tls:
76
+ smtp.starttls(context=context)
77
+
78
+ # Authenticate if credentials provided
79
+ if self.config.smtp_username and self.config.smtp_password:
80
+ smtp.login(
81
+ self.config.smtp_username,
82
+ self.config.smtp_password
83
+ )
84
+
85
+ return smtp
86
+
87
+ def _render_template(
88
+ self,
89
+ template_name: str,
90
+ context: Dict[str, Any]
91
+ ) -> tuple[str, str]:
92
+ """
93
+ Render email template
94
+
95
+ Args:
96
+ template_name: Template name (without extension)
97
+ context: Template context
98
+
99
+ Returns:
100
+ Tuple of (html_content, text_content)
101
+ """
102
+ if not self.template_env:
103
+ # Return simple text if no templates
104
+ return None, self._get_default_text(template_name, context)
105
+
106
+ try:
107
+ # Try to render HTML template
108
+ html_template = self.template_env.get_template(f"{template_name}.html")
109
+ html_content = html_template.render(**context)
110
+ except Exception:
111
+ html_content = None
112
+
113
+ try:
114
+ # Try to render text template
115
+ text_template = self.template_env.get_template(f"{template_name}.txt")
116
+ text_content = text_template.render(**context)
117
+ except Exception:
118
+ text_content = self._get_default_text(template_name, context)
119
+
120
+ return html_content, text_content
121
+
122
+ def _get_default_text(
123
+ self,
124
+ template_name: str,
125
+ context: Dict[str, Any]
126
+ ) -> str:
127
+ """
128
+ Get default text content for email
129
+
130
+ Args:
131
+ template_name: Template name
132
+ context: Template context
133
+
134
+ Returns:
135
+ Default text content
136
+ """
137
+ defaults = {
138
+ "verify_email": f"Please verify your email by clicking: {context.get('verify_url', '')}",
139
+ "reset_password": f"Reset your password by clicking: {context.get('reset_url', '')}",
140
+ "2fa_enabled": "Two-factor authentication has been enabled on your account.",
141
+ "2fa_disabled": "Two-factor authentication has been disabled on your account.",
142
+ "passkey_added": f"A new passkey '{context.get('passkey_name', 'Unknown')}' has been added to your account.",
143
+ "security_alert": f"Security alert: {context.get('alert_message', 'Unknown activity detected')}",
144
+ "welcome": f"Welcome to {self.config.app_name}!"
145
+ }
146
+
147
+ return defaults.get(template_name, "Email notification from " + self.config.app_name)
148
+
149
+ async def send_email(
150
+ self,
151
+ to_email: str,
152
+ subject: str,
153
+ template_name: str,
154
+ context: Dict[str, Any],
155
+ cc: Optional[list[str]] = None,
156
+ bcc: Optional[list[str]] = None
157
+ ) -> bool:
158
+ """
159
+ Send email using template
160
+
161
+ Args:
162
+ to_email: Recipient email
163
+ subject: Email subject
164
+ template_name: Template name
165
+ context: Template context
166
+ cc: CC recipients
167
+ bcc: BCC recipients
168
+
169
+ Returns:
170
+ True if sent successfully
171
+ """
172
+ try:
173
+ # Add common context
174
+ context.update({
175
+ "app_name": self.config.app_name,
176
+ "app_url": self.config.app_url,
177
+ "support_email": self.config.support_email,
178
+ "year": datetime.utcnow().year
179
+ })
180
+
181
+ # Render template
182
+ html_content, text_content = self._render_template(template_name, context)
183
+
184
+ # Create message
185
+ msg = MIMEMultipart('alternative')
186
+ msg['Subject'] = subject
187
+ msg['From'] = self.config.email_from
188
+ msg['To'] = to_email
189
+
190
+ if cc:
191
+ msg['Cc'] = ', '.join(cc)
192
+ if bcc:
193
+ msg['Bcc'] = ', '.join(bcc)
194
+
195
+ # Add text part
196
+ text_part = MIMEText(text_content, 'plain')
197
+ msg.attach(text_part)
198
+
199
+ # Add HTML part if available
200
+ if html_content:
201
+ html_part = MIMEText(html_content, 'html')
202
+ msg.attach(html_part)
203
+
204
+ # Send email
205
+ with self._create_smtp_connection() as smtp:
206
+ recipients = [to_email]
207
+ if cc:
208
+ recipients.extend(cc)
209
+ if bcc:
210
+ recipients.extend(bcc)
211
+
212
+ smtp.send_message(msg, to_addrs=recipients)
213
+
214
+ logger.info(f"Email sent successfully to {to_email}")
215
+ return True
216
+
217
+ except Exception as e:
218
+ logger.error(f"Failed to send email to {to_email}: {e}")
219
+ return False
220
+
221
+ async def send_verification_email(
222
+ self,
223
+ user_email: str,
224
+ user_name: Optional[str] = None
225
+ ) -> bool:
226
+ """
227
+ Send email verification
228
+
229
+ Args:
230
+ user_email: User email
231
+ user_name: User's name
232
+
233
+ Returns:
234
+ True if sent successfully
235
+ """
236
+ # Generate verification token
237
+ token = generate_email_verification_token(
238
+ user_email,
239
+ self.config.jwt_secret
240
+ )
241
+
242
+ # Build verification URL
243
+ verify_url = f"{self.config.app_url}/auth/verify-email/{token}"
244
+
245
+ # Send email
246
+ success = await self.send_email(
247
+ to_email=user_email,
248
+ subject=f"Verify your email for {self.config.app_name}",
249
+ template_name="verify_email",
250
+ context={
251
+ "user_name": user_name or user_email,
252
+ "verify_url": verify_url,
253
+ "expires_in": "24 hours"
254
+ }
255
+ )
256
+
257
+ if success:
258
+ await auth_events.emit("email_verification_sent", {
259
+ "email": user_email
260
+ })
261
+
262
+ return success
263
+
264
+ async def send_password_reset_email(
265
+ self,
266
+ user_email: str,
267
+ user_name: Optional[str] = None
268
+ ) -> bool:
269
+ """
270
+ Send password reset email
271
+
272
+ Args:
273
+ user_email: User email
274
+ user_name: User's name
275
+
276
+ Returns:
277
+ True if sent successfully
278
+ """
279
+ # Generate reset token
280
+ token = generate_password_reset_token(
281
+ user_email,
282
+ self.config.jwt_secret
283
+ )
284
+
285
+ # Build reset URL
286
+ reset_url = f"{self.config.app_url}/auth/reset-password/{token}"
287
+
288
+ # Send email
289
+ success = await self.send_email(
290
+ to_email=user_email,
291
+ subject=f"Reset your password for {self.config.app_name}",
292
+ template_name="reset_password",
293
+ context={
294
+ "user_name": user_name or user_email,
295
+ "reset_url": reset_url,
296
+ "expires_in": "1 hour"
297
+ }
298
+ )
299
+
300
+ if success:
301
+ await auth_events.emit("password_reset_email_sent", {
302
+ "email": user_email
303
+ })
304
+
305
+ return success
306
+
307
+ async def send_2fa_enabled_email(
308
+ self,
309
+ user_email: str,
310
+ user_name: Optional[str] = None,
311
+ device_info: Optional[Dict[str, str]] = None
312
+ ) -> bool:
313
+ """
314
+ Send 2FA enabled notification
315
+
316
+ Args:
317
+ user_email: User email
318
+ user_name: User's name
319
+ device_info: Device information
320
+
321
+ Returns:
322
+ True if sent successfully
323
+ """
324
+ return await self.send_email(
325
+ to_email=user_email,
326
+ subject=f"Two-factor authentication enabled on {self.config.app_name}",
327
+ template_name="2fa_enabled",
328
+ context={
329
+ "user_name": user_name or user_email,
330
+ "enabled_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC"),
331
+ "device_info": device_info
332
+ }
333
+ )
334
+
335
+ async def send_2fa_disabled_email(
336
+ self,
337
+ user_email: str,
338
+ user_name: Optional[str] = None,
339
+ device_info: Optional[Dict[str, str]] = None
340
+ ) -> bool:
341
+ """
342
+ Send 2FA disabled notification
343
+
344
+ Args:
345
+ user_email: User email
346
+ user_name: User's name
347
+ device_info: Device information
348
+
349
+ Returns:
350
+ True if sent successfully
351
+ """
352
+ return await self.send_email(
353
+ to_email=user_email,
354
+ subject=f"Two-factor authentication disabled on {self.config.app_name}",
355
+ template_name="2fa_disabled",
356
+ context={
357
+ "user_name": user_name or user_email,
358
+ "disabled_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC"),
359
+ "device_info": device_info
360
+ }
361
+ )
362
+
363
+ async def send_passkey_added_email(
364
+ self,
365
+ user_email: str,
366
+ passkey_name: str,
367
+ user_name: Optional[str] = None,
368
+ device_info: Optional[Dict[str, str]] = None
369
+ ) -> bool:
370
+ """
371
+ Send passkey added notification
372
+
373
+ Args:
374
+ user_email: User email
375
+ passkey_name: Passkey name
376
+ user_name: User's name
377
+ device_info: Device information
378
+
379
+ Returns:
380
+ True if sent successfully
381
+ """
382
+ return await self.send_email(
383
+ to_email=user_email,
384
+ subject=f"New passkey added to your {self.config.app_name} account",
385
+ template_name="passkey_added",
386
+ context={
387
+ "user_name": user_name or user_email,
388
+ "passkey_name": passkey_name,
389
+ "added_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC"),
390
+ "device_info": device_info
391
+ }
392
+ )
393
+
394
+ async def send_security_alert_email(
395
+ self,
396
+ user_email: str,
397
+ alert_type: str,
398
+ alert_message: str,
399
+ user_name: Optional[str] = None,
400
+ device_info: Optional[Dict[str, str]] = None,
401
+ action_url: Optional[str] = None
402
+ ) -> bool:
403
+ """
404
+ Send security alert email
405
+
406
+ Args:
407
+ user_email: User email
408
+ alert_type: Type of alert
409
+ alert_message: Alert message
410
+ user_name: User's name
411
+ device_info: Device information
412
+ action_url: URL for user action
413
+
414
+ Returns:
415
+ True if sent successfully
416
+ """
417
+ return await self.send_email(
418
+ to_email=user_email,
419
+ subject=f"Security alert for your {self.config.app_name} account",
420
+ template_name="security_alert",
421
+ context={
422
+ "user_name": user_name or user_email,
423
+ "alert_type": alert_type,
424
+ "alert_message": alert_message,
425
+ "alert_time": datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC"),
426
+ "device_info": device_info,
427
+ "action_url": action_url or f"{self.config.app_url}/account/security"
428
+ }
429
+ )
430
+
431
+ async def send_welcome_email(
432
+ self,
433
+ user_email: str,
434
+ user_name: Optional[str] = None,
435
+ verification_required: bool = False
436
+ ) -> bool:
437
+ """
438
+ Send welcome email to new user
439
+
440
+ Args:
441
+ user_email: User email
442
+ user_name: User's name
443
+ verification_required: Whether email verification is required
444
+
445
+ Returns:
446
+ True if sent successfully
447
+ """
448
+ context = {
449
+ "user_name": user_name or user_email,
450
+ "getting_started_url": f"{self.config.app_url}/getting-started",
451
+ "help_url": f"{self.config.app_url}/help",
452
+ "verification_required": verification_required
453
+ }
454
+
455
+ if verification_required:
456
+ # Include verification token
457
+ token = generate_email_verification_token(
458
+ user_email,
459
+ self.config.jwt_secret
460
+ )
461
+ context["verify_url"] = f"{self.config.app_url}/auth/verify-email/{token}"
462
+
463
+ return await self.send_email(
464
+ to_email=user_email,
465
+ subject=f"Welcome to {self.config.app_name}!",
466
+ template_name="welcome",
467
+ context=context
468
+ )