nextpy-framework 1.0.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.
Files changed (49) hide show
  1. nextpy/__init__.py +50 -0
  2. nextpy/auth.py +94 -0
  3. nextpy/builder.py +123 -0
  4. nextpy/cli.py +490 -0
  5. nextpy/components/__init__.py +45 -0
  6. nextpy/components/feedback.py +210 -0
  7. nextpy/components/form.py +346 -0
  8. nextpy/components/head.py +167 -0
  9. nextpy/components/hooks_provider.py +64 -0
  10. nextpy/components/image.py +180 -0
  11. nextpy/components/layout.py +206 -0
  12. nextpy/components/link.py +132 -0
  13. nextpy/components/loader.py +65 -0
  14. nextpy/components/toast.py +101 -0
  15. nextpy/components/visual.py +185 -0
  16. nextpy/config.py +75 -0
  17. nextpy/core/__init__.py +21 -0
  18. nextpy/core/builder.py +237 -0
  19. nextpy/core/data_fetching.py +221 -0
  20. nextpy/core/renderer.py +252 -0
  21. nextpy/core/router.py +233 -0
  22. nextpy/core/sync.py +34 -0
  23. nextpy/db.py +121 -0
  24. nextpy/dev_server.py +69 -0
  25. nextpy/dev_tools.py +157 -0
  26. nextpy/errors.py +70 -0
  27. nextpy/hooks.py +348 -0
  28. nextpy/performance.py +78 -0
  29. nextpy/plugins.py +61 -0
  30. nextpy/py.typed +0 -0
  31. nextpy/server/__init__.py +6 -0
  32. nextpy/server/app.py +325 -0
  33. nextpy/server/debug.py +93 -0
  34. nextpy/server/middleware.py +88 -0
  35. nextpy/utils/__init__.py +0 -0
  36. nextpy/utils/cache.py +89 -0
  37. nextpy/utils/email.py +59 -0
  38. nextpy/utils/file_upload.py +65 -0
  39. nextpy/utils/logging.py +52 -0
  40. nextpy/utils/search.py +59 -0
  41. nextpy/utils/seo.py +85 -0
  42. nextpy/utils/validators.py +58 -0
  43. nextpy/websocket.py +76 -0
  44. nextpy_framework-1.0.0.dist-info/METADATA +343 -0
  45. nextpy_framework-1.0.0.dist-info/RECORD +49 -0
  46. nextpy_framework-1.0.0.dist-info/WHEEL +5 -0
  47. nextpy_framework-1.0.0.dist-info/entry_points.txt +2 -0
  48. nextpy_framework-1.0.0.dist-info/licenses/LICENSE +21 -0
  49. nextpy_framework-1.0.0.dist-info/top_level.txt +1 -0
nextpy/server/app.py ADDED
@@ -0,0 +1,325 @@
1
+ """
2
+ NextPy Server Application - FastAPI-based server
3
+ Handles routing, SSR, API routes, and static file serving
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ import asyncio
9
+ import importlib.util
10
+ from pathlib import Path
11
+ from typing import Any, Callable, Dict, Optional
12
+
13
+ from fastapi import FastAPI, Request, Response, HTTPException
14
+ from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
15
+ from fastapi.staticfiles import StaticFiles
16
+ from starlette.middleware.cors import CORSMiddleware
17
+
18
+ from nextpy.core.router import Router
19
+ from nextpy.core.renderer import Renderer
20
+ from nextpy.core.data_fetching import (
21
+ PageContext,
22
+ execute_data_fetching,
23
+ PageNotFoundError,
24
+ RedirectError,
25
+ )
26
+ from nextpy.server.middleware import NextPyMiddleware
27
+
28
+
29
+ class NextPyApp:
30
+ """
31
+ Main NextPy application class
32
+ Wraps FastAPI and provides Next.js-like functionality
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ pages_dir: str = "pages",
38
+ templates_dir: str = "templates",
39
+ public_dir: str = "public",
40
+ out_dir: str = "out",
41
+ debug: bool = False,
42
+ ):
43
+ self.pages_dir = Path(pages_dir)
44
+ self.templates_dir = Path(templates_dir)
45
+ self.public_dir = Path(public_dir)
46
+ self.out_dir = Path(out_dir)
47
+ self.debug = debug
48
+ self._modules_cache: Dict[str, Any] = {}
49
+
50
+ self.router = Router(str(self.pages_dir), str(self.templates_dir))
51
+ self.renderer = Renderer(
52
+ str(self.templates_dir),
53
+ str(self.pages_dir),
54
+ str(self.public_dir),
55
+ )
56
+
57
+ self.app = FastAPI(
58
+ title="NextPy Application",
59
+ debug=debug,
60
+ )
61
+
62
+ self._setup_middleware()
63
+ self._setup_static_files()
64
+ self._setup_routes()
65
+
66
+ def _setup_middleware(self) -> None:
67
+ """Configure middleware"""
68
+ self.app.add_middleware(
69
+ CORSMiddleware,
70
+ allow_origins=["*"],
71
+ allow_credentials=True,
72
+ allow_methods=["*"],
73
+ allow_headers=["*"],
74
+ )
75
+
76
+ self.app.add_middleware(NextPyMiddleware)
77
+
78
+ def _setup_static_files(self) -> None:
79
+ """Mount static file directories"""
80
+ if self.public_dir.exists():
81
+ self.app.mount(
82
+ "/static",
83
+ StaticFiles(directory=str(self.public_dir)),
84
+ name="static",
85
+ )
86
+
87
+ nextpy_static = self.out_dir / "_nextpy" / "static"
88
+ if nextpy_static.exists():
89
+ self.app.mount(
90
+ "/_nextpy/static",
91
+ StaticFiles(directory=str(nextpy_static)),
92
+ name="nextpy_static",
93
+ )
94
+
95
+ def _setup_routes(self) -> None:
96
+ """Set up the catch-all route handler"""
97
+ self.router.scan_pages()
98
+
99
+ @self.app.get("/")
100
+ async def index(request: Request) -> Response:
101
+ return await self._handle_request(request, "/")
102
+
103
+ @self.app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
104
+ async def catch_all(request: Request, path: str = "") -> Response:
105
+ return await self._handle_request(request, f"/{path}")
106
+
107
+ def _load_module_from_file(self, file_path: Path) -> Optional[Any]:
108
+ """Load a Python module from a file path"""
109
+ try:
110
+ module_name = f"nextpy_page_{file_path.stem}_{hash(str(file_path))}"
111
+
112
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
113
+ if spec and spec.loader:
114
+ module = importlib.util.module_from_spec(spec)
115
+ sys.modules[module_name] = module
116
+ spec.loader.exec_module(module)
117
+ return module
118
+ except Exception as e:
119
+ if self.debug:
120
+ print(f"Error loading module from {file_path}: {e}")
121
+ return None
122
+
123
+ async def _handle_request(self, request: Request, path: str) -> Response:
124
+ """Handle a page request"""
125
+ match = self.router.match(path)
126
+
127
+ if not match:
128
+ return await self._render_404(request)
129
+
130
+ route, params = match
131
+
132
+ if route.is_api:
133
+ return await self._handle_api_request(request, route, params)
134
+
135
+ context = PageContext(
136
+ params=params,
137
+ query=dict(request.query_params),
138
+ req=request,
139
+ )
140
+
141
+ try:
142
+ props = {}
143
+ module = self._load_module_from_file(route.file_path)
144
+
145
+ if module:
146
+ props = await execute_data_fetching(module, context)
147
+
148
+ template_name = self._get_template_name(route, module)
149
+
150
+ html = await self.renderer.render_async(
151
+ template_name,
152
+ context={
153
+ **props,
154
+ "params": params,
155
+ "query": dict(request.query_params),
156
+ "request": request,
157
+ },
158
+ )
159
+
160
+ return HTMLResponse(
161
+ content=html,
162
+ headers={
163
+ "Cache-Control": "no-cache, no-store, must-revalidate",
164
+ "Pragma": "no-cache",
165
+ "Expires": "0",
166
+ },
167
+ )
168
+
169
+ except PageNotFoundError:
170
+ return await self._render_404(request)
171
+
172
+ except RedirectError as e:
173
+ status_code = 308 if e.permanent else 307
174
+ return RedirectResponse(url=e.destination, status_code=status_code)
175
+
176
+ except Exception as e:
177
+ if self.debug:
178
+ import traceback
179
+ traceback.print_exc()
180
+ return await self._render_error(request, e)
181
+
182
+ async def _handle_api_request(
183
+ self,
184
+ request: Request,
185
+ route,
186
+ params: Optional[Dict[str, str]] = None
187
+ ) -> Response:
188
+ """Handle an API route request"""
189
+ params = params or {}
190
+
191
+ module = self._load_module_from_file(route.file_path)
192
+ if not module:
193
+ return JSONResponse(
194
+ {"error": "Module not found"},
195
+ status_code=500,
196
+ )
197
+
198
+ method = request.method.lower()
199
+ handler = getattr(module, method, None) or getattr(module, method.upper(), None)
200
+
201
+ if not handler:
202
+ handler = getattr(module, "handler", None) or getattr(module, "default", None)
203
+
204
+ if not handler:
205
+ return JSONResponse(
206
+ {"error": f"No handler for {request.method}"},
207
+ status_code=405,
208
+ )
209
+
210
+ try:
211
+ if asyncio.iscoroutinefunction(handler):
212
+ result = await handler(request, params)
213
+ else:
214
+ result = handler(request, params)
215
+
216
+ if isinstance(result, Response):
217
+ return result
218
+ elif isinstance(result, dict):
219
+ return JSONResponse(result)
220
+ else:
221
+ return JSONResponse({"data": result})
222
+
223
+ except Exception as e:
224
+ if self.debug:
225
+ import traceback
226
+ traceback.print_exc()
227
+ return JSONResponse(
228
+ {"error": str(e)},
229
+ status_code=500,
230
+ )
231
+
232
+ def _get_template_name(self, route, module: Optional[Any] = None) -> str:
233
+ """Get the template name for a route"""
234
+ if module and hasattr(module, "get_template"):
235
+ return module.get_template()
236
+
237
+ relative = route.file_path.relative_to(self.pages_dir)
238
+ template_name = str(relative).replace(".py", ".html")
239
+
240
+ template_path = self.templates_dir / template_name
241
+ if template_path.exists():
242
+ return template_name
243
+
244
+ if route.file_path.stem == "index":
245
+ parent_template = self.templates_dir / route.file_path.parent.name / "index.html"
246
+ if parent_template.exists():
247
+ return str(parent_template.relative_to(self.templates_dir))
248
+
249
+ return "_page.html"
250
+
251
+ async def _render_404(self, request: Request) -> HTMLResponse:
252
+ """Render the 404 page"""
253
+ try:
254
+ html = await self.renderer.render_async(
255
+ "_404.html",
256
+ context={"request": request},
257
+ )
258
+ except Exception:
259
+ html = """
260
+ <!DOCTYPE html>
261
+ <html>
262
+ <head><title>404 - Not Found</title></head>
263
+ <body>
264
+ <h1>404 - Page Not Found</h1>
265
+ <p>The page you're looking for doesn't exist.</p>
266
+ </body>
267
+ </html>
268
+ """
269
+ return HTMLResponse(content=html, status_code=404)
270
+
271
+ async def _render_error(self, request: Request, error: Exception) -> HTMLResponse:
272
+ """Render the error page"""
273
+ try:
274
+ html = await self.renderer.render_async(
275
+ "_error.html",
276
+ context={"request": request, "error": str(error)},
277
+ )
278
+ except Exception:
279
+ html = f"""
280
+ <!DOCTYPE html>
281
+ <html>
282
+ <head><title>500 - Server Error</title></head>
283
+ <body>
284
+ <h1>500 - Server Error</h1>
285
+ <p>An error occurred while processing your request.</p>
286
+ </body>
287
+ </html>
288
+ """
289
+ return HTMLResponse(content=html, status_code=500)
290
+
291
+ def reload_routes(self) -> None:
292
+ """Reload all routes (for hot reload)"""
293
+ self._modules_cache.clear()
294
+ self.router = Router(str(self.pages_dir), str(self.templates_dir))
295
+ self.router.scan_pages()
296
+
297
+
298
+ def create_app(
299
+ pages_dir: str = "pages",
300
+ templates_dir: str = "templates",
301
+ public_dir: str = "public",
302
+ out_dir: str = "out",
303
+ debug: bool = False,
304
+ ) -> FastAPI:
305
+ """
306
+ Factory function to create a NextPy application
307
+
308
+ Args:
309
+ pages_dir: Directory containing page files
310
+ templates_dir: Directory containing Jinja2 templates
311
+ public_dir: Directory containing static files
312
+ out_dir: Directory for SSG output
313
+ debug: Enable debug mode
314
+
315
+ Returns:
316
+ FastAPI application instance
317
+ """
318
+ nextpy_app = NextPyApp(
319
+ pages_dir=pages_dir,
320
+ templates_dir=templates_dir,
321
+ public_dir=public_dir,
322
+ out_dir=out_dir,
323
+ debug=debug,
324
+ )
325
+ return nextpy_app.app
nextpy/server/debug.py ADDED
@@ -0,0 +1,93 @@
1
+ """
2
+ Debug utilities for NextPy development
3
+ Provides error tracking, logging, and debug panel support
4
+ """
5
+
6
+ import traceback
7
+ import sys
8
+ from typing import Optional, Dict, Any
9
+ from fastapi import Request
10
+ from starlette.responses import HTMLResponse
11
+
12
+
13
+ class ErrorTracker:
14
+ """Track and format errors for debug panel"""
15
+
16
+ def __init__(self):
17
+ self.last_error: Optional[Dict[str, Any]] = None
18
+
19
+ def capture_error(self, exc: Exception, request: Optional[Request] = None) -> Dict[str, Any]:
20
+ """Capture error details"""
21
+ tb = traceback.format_exc()
22
+ exc_type, exc_value, exc_tb = sys.exc_info()
23
+
24
+ # Get file and line number
25
+ tb_list = traceback.extract_tb(exc_tb)
26
+ last_frame = tb_list[-1] if tb_list else None
27
+
28
+ error_data = {
29
+ "type": exc.__class__.__name__,
30
+ "message": str(exc),
31
+ "traceback": tb,
32
+ "file": last_frame.filename if last_frame else None,
33
+ "line": last_frame.lineno if last_frame else None,
34
+ "path": request.url.path if request else None,
35
+ }
36
+
37
+ self.last_error = error_data
38
+ return error_data
39
+
40
+ def get_last_error(self) -> Optional[Dict[str, Any]]:
41
+ """Get last captured error"""
42
+ return self.last_error
43
+
44
+ def clear_error(self):
45
+ """Clear last error"""
46
+ self.last_error = None
47
+
48
+
49
+ error_tracker = ErrorTracker()
50
+
51
+
52
+ async def render_error_page(error: Dict[str, Any]) -> HTMLResponse:
53
+ """Render error page with debug info"""
54
+ html = f"""
55
+ <!DOCTYPE html>
56
+ <html>
57
+ <head>
58
+ <title>NextPy Error</title>
59
+ <script src="https://cdn.tailwindcss.com"></script>
60
+ <style>
61
+ .animate-pulse {{ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }}
62
+ @keyframes pulse {{
63
+ 0%, 100% {{ opacity: 1; }}
64
+ 50% {{ opacity: .5; }}
65
+ }}
66
+ </style>
67
+ </head>
68
+ <body class="bg-gray-900">
69
+ <div class="fixed bottom-0 left-0 right-0 z-50 bg-red-950 text-red-100 border-t-4 border-red-600 shadow-2xl">
70
+ <div class="max-w-full">
71
+ <div class="flex items-center justify-between p-4 bg-red-900">
72
+ <div class="flex items-center gap-3">
73
+ <span class="text-2xl">⚠️</span>
74
+ <div>
75
+ <h3 class="font-bold text-lg">{error.get('type', 'Error')}</h3>
76
+ <p class="text-sm text-red-200">{error.get('message', 'An error occurred')}</p>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ <div class="p-4">
81
+ <h4 class="font-mono font-bold text-sm mb-2">Traceback:</h4>
82
+ <pre class="font-mono text-xs bg-black bg-opacity-30 p-3 rounded overflow-x-auto text-red-100">{error.get('traceback', '')}</pre>
83
+ </div>
84
+ <div class="p-4 border-t border-red-800">
85
+ <h4 class="font-mono font-bold text-sm mb-2">Location:</h4>
86
+ <p class="text-sm"><span class="text-red-300">{error.get('file', 'unknown')}</span> line <span class="font-bold">{error.get('line', '?')}</span></p>
87
+ </div>
88
+ </div>
89
+ </div>
90
+ </body>
91
+ </html>
92
+ """
93
+ return HTMLResponse(html, status_code=500)
@@ -0,0 +1,88 @@
1
+ """
2
+ NextPy Middleware - Request/Response middleware for the server
3
+ """
4
+
5
+ import time
6
+ from typing import Callable
7
+ from starlette.middleware.base import BaseHTTPMiddleware
8
+ from starlette.requests import Request
9
+ from starlette.responses import Response
10
+
11
+
12
+ class NextPyMiddleware(BaseHTTPMiddleware):
13
+ """
14
+ Core middleware for NextPy applications
15
+ Handles:
16
+ - Request timing
17
+ - Cache headers
18
+ - Security headers
19
+ - HTMX detection
20
+ """
21
+
22
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
23
+ start_time = time.time()
24
+
25
+ request.state.is_htmx = request.headers.get("HX-Request") == "true"
26
+ request.state.htmx_target = request.headers.get("HX-Target")
27
+ request.state.htmx_trigger = request.headers.get("HX-Trigger")
28
+
29
+ response = await call_next(request)
30
+
31
+ process_time = time.time() - start_time
32
+ response.headers["X-Process-Time"] = str(process_time)
33
+
34
+ response.headers["X-Content-Type-Options"] = "nosniff"
35
+ response.headers["X-Frame-Options"] = "SAMEORIGIN"
36
+ response.headers["X-XSS-Protection"] = "1; mode=block"
37
+
38
+ if request.state.is_htmx:
39
+ response.headers["HX-Push-Url"] = str(request.url.path)
40
+
41
+ return response
42
+
43
+
44
+ class AuthMiddleware(BaseHTTPMiddleware):
45
+ """
46
+ Optional authentication middleware
47
+ Can be added to protect routes
48
+ """
49
+
50
+ def __init__(self, app, protected_paths: list = None, login_path: str = "/login"):
51
+ super().__init__(app)
52
+ self.protected_paths = protected_paths or []
53
+ self.login_path = login_path
54
+
55
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
56
+ path = request.url.path
57
+
58
+ for protected in self.protected_paths:
59
+ if path.startswith(protected):
60
+ session = request.cookies.get("session")
61
+ if not session:
62
+ from starlette.responses import RedirectResponse
63
+ return RedirectResponse(
64
+ url=f"{self.login_path}?next={path}",
65
+ status_code=302,
66
+ )
67
+
68
+ return await call_next(request)
69
+
70
+
71
+ class CacheMiddleware(BaseHTTPMiddleware):
72
+ """
73
+ Caching middleware for static content
74
+ """
75
+
76
+ CACHE_EXTENSIONS = {".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2"}
77
+
78
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
79
+ response = await call_next(request)
80
+
81
+ path = request.url.path
82
+
83
+ if any(path.endswith(ext) for ext in self.CACHE_EXTENSIONS):
84
+ response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
85
+ elif path.startswith("/_nextpy/"):
86
+ response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
87
+
88
+ return response
File without changes
nextpy/utils/cache.py ADDED
@@ -0,0 +1,89 @@
1
+ """
2
+ NextPy Caching Utilities
3
+ Simple in-memory cache with TTL support
4
+ """
5
+
6
+ import time
7
+ from typing import Any, Optional, Dict, Callable
8
+ import functools
9
+
10
+
11
+ class Cache:
12
+ """Simple in-memory cache with TTL"""
13
+
14
+ def __init__(self):
15
+ self._store: Dict[str, tuple] = {}
16
+
17
+ def set(self, key: str, value: Any, ttl: int = 3600):
18
+ """Set cache value with TTL in seconds"""
19
+ self._store[key] = (value, time.time() + ttl)
20
+
21
+ def get(self, key: str) -> Optional[Any]:
22
+ """Get cache value if not expired"""
23
+ if key not in self._store:
24
+ return None
25
+
26
+ value, expiry = self._store[key]
27
+ if time.time() > expiry:
28
+ del self._store[key]
29
+ return None
30
+
31
+ return value
32
+
33
+ def delete(self, key: str):
34
+ """Delete cache value"""
35
+ if key in self._store:
36
+ del self._store[key]
37
+
38
+ def clear(self):
39
+ """Clear all cache"""
40
+ self._store.clear()
41
+
42
+ def cleanup_expired(self):
43
+ """Remove expired entries"""
44
+ now = time.time()
45
+ expired = [k for k, (_, expiry) in self._store.items() if now > expiry]
46
+ for k in expired:
47
+ del self._store[k]
48
+
49
+
50
+ # Global cache instance
51
+ _cache = Cache()
52
+
53
+
54
+ def get_cache() -> Cache:
55
+ """Get global cache instance"""
56
+ return _cache
57
+
58
+
59
+ def cache_result(ttl: int = 3600):
60
+ """Decorator to cache function results"""
61
+ def decorator(func: Callable) -> Callable:
62
+ @functools.wraps(func)
63
+ async def async_wrapper(*args, **kwargs):
64
+ key = f"{func.__name__}:{str(args)}:{str(kwargs)}"
65
+ cached = _cache.get(key)
66
+ if cached is not None:
67
+ return cached
68
+ result = await func(*args, **kwargs)
69
+ _cache.set(key, result, ttl)
70
+ return result
71
+
72
+ @functools.wraps(func)
73
+ def sync_wrapper(*args, **kwargs):
74
+ key = f"{func.__name__}:{str(args)}:{str(kwargs)}"
75
+ cached = _cache.get(key)
76
+ if cached is not None:
77
+ return cached
78
+ result = func(*args, **kwargs)
79
+ _cache.set(key, result, ttl)
80
+ return result
81
+
82
+ if hasattr(func, '__call__'):
83
+ import inspect
84
+ if inspect.iscoroutinefunction(func):
85
+ return async_wrapper
86
+
87
+ return sync_wrapper
88
+
89
+ return decorator
nextpy/utils/email.py ADDED
@@ -0,0 +1,59 @@
1
+ """
2
+ NextPy Email Utilities
3
+ Send emails via SMTP
4
+ """
5
+
6
+ import smtplib
7
+ from email.mime.text import MIMEText
8
+ from email.mime.multipart import MIMEMultipart
9
+ from typing import List, Optional
10
+ from nextpy.config import settings
11
+
12
+
13
+ async def send_email(
14
+ to: List[str],
15
+ subject: str,
16
+ html_content: str,
17
+ text_content: Optional[str] = None,
18
+ ) -> bool:
19
+ """Send email via SMTP"""
20
+ try:
21
+ msg = MIMEMultipart("alternative")
22
+ msg["Subject"] = subject
23
+ msg["From"] = settings.mail_username
24
+ msg["To"] = ", ".join(to)
25
+
26
+ if text_content:
27
+ msg.attach(MIMEText(text_content, "plain"))
28
+ msg.attach(MIMEText(html_content, "html"))
29
+
30
+ with smtplib.SMTP(settings.mail_server, settings.mail_port) as server:
31
+ server.starttls()
32
+ server.login(settings.mail_username, settings.mail_password)
33
+ server.sendmail(settings.mail_username, to, msg.as_string())
34
+
35
+ return True
36
+ except Exception as e:
37
+ print(f"Email failed: {e}")
38
+ return False
39
+
40
+
41
+ async def send_welcome_email(email: str, name: str) -> bool:
42
+ """Send welcome email"""
43
+ html = f"""
44
+ <h2>Welcome {name}!</h2>
45
+ <p>Thanks for joining NextPy.</p>
46
+ <p>Start building amazing apps today.</p>
47
+ """
48
+ return await send_email([email], "Welcome to NextPy", html)
49
+
50
+
51
+ async def send_reset_password_email(email: str, reset_link: str) -> bool:
52
+ """Send password reset email"""
53
+ html = f"""
54
+ <h2>Reset Your Password</h2>
55
+ <p>Click the link below to reset your password:</p>
56
+ <a href="{reset_link}">Reset Password</a>
57
+ <p>This link expires in 24 hours.</p>
58
+ """
59
+ return await send_email([email], "Reset Your Password", html)