YurMail 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,4 @@
1
+ from .client import OpenAIDraftClient
2
+ from .types import DraftResult
3
+
4
+ __all__ = ["OpenAIDraftClient", "DraftResult"]
YurMail/LLM/client.py ADDED
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from openai import AsyncOpenAI
4
+
5
+ from .prompts import build_draft_prompt
6
+ from .types import DraftResult
7
+
8
+
9
+ class OpenAIDraftClient:
10
+ """
11
+ Small async client for generating email drafts with OpenAI.
12
+ """
13
+
14
+ def __init__(self, api_key: str | None = None, model: str = "gpt-5.4-mini"):
15
+ self._client = AsyncOpenAI(api_key=api_key)
16
+ self._model = model
17
+
18
+ async def test_connection(self, message: str) -> str:
19
+ """
20
+ Simple helper to verify that the LLM connection works.
21
+ """
22
+ response = await self._client.responses.create(
23
+ model=self._model,
24
+ input=message,
25
+ )
26
+ return response.output_text.strip()
27
+
28
+ async def generate_draft(
29
+ self,
30
+ purpose: str,
31
+ recipient_name: str | None = None,
32
+ tone: str = "professional",
33
+ context: str | None = None,
34
+ language: str = "es",
35
+ ) -> DraftResult:
36
+ """
37
+ Generate an email draft and return structured subject/body output.
38
+ """
39
+ prompt = build_draft_prompt(
40
+ purpose=purpose,
41
+ recipient_name=recipient_name,
42
+ tone=tone,
43
+ context=context,
44
+ language=language,
45
+ )
46
+
47
+ response = await self._client.responses.create(
48
+ model=self._model,
49
+ input=prompt,
50
+ )
51
+
52
+ text = response.output_text.strip()
53
+ subject, body = self._parse_subject_and_body(text)
54
+
55
+ return DraftResult(subject=subject, body=body, raw=text)
56
+
57
+ @staticmethod
58
+ def _parse_subject_and_body(text: str) -> tuple[str, str]:
59
+ """
60
+ Parse a response of the form:
61
+
62
+ SUBJECT: ...
63
+ BODY: ...
64
+ """
65
+ subject = ""
66
+ body = text
67
+
68
+ if "SUBJECT:" in text and "BODY:" in text:
69
+ before, after = text.split("BODY:", 1)
70
+ subject = before.replace("SUBJECT:", "").strip()
71
+ body = after.strip()
72
+
73
+ return subject, body
YurMail/LLM/prompts.py ADDED
@@ -0,0 +1,46 @@
1
+ def build_draft_prompt(
2
+ purpose: str,
3
+ recipient_name: str | None = None,
4
+ tone: str = "professional",
5
+ context: str | None = None,
6
+ language: str = "es",
7
+ ) -> str:
8
+ """
9
+ Build a prompt for generating an email draft.
10
+
11
+ Parameters
12
+ ----------
13
+ purpose:
14
+ Main objective of the email.
15
+ recipient_name:
16
+ Optional recipient name.
17
+ tone:
18
+ Desired writing tone.
19
+ context:
20
+ Extra context or constraints.
21
+ language:
22
+ Output language, e.g. "es" or "en".
23
+ """
24
+ recipient_text = recipient_name or "destinatario"
25
+ context_text = context or "Sin contexto adicional."
26
+
27
+ return f"""
28
+ Redacta un correo electrónico en {language}.
29
+
30
+ Objetivo: {purpose}
31
+ Destinatario: {recipient_text}
32
+ Tono: {tone}
33
+ Contexto adicional: {context_text}
34
+
35
+ Instrucciones:
36
+ - Escribe un asunto claro y profesional.
37
+ - Escribe un cuerpo de correo natural, bien redactado y coherente.
38
+ - No inventes hechos que no aparezcan en el contexto.
39
+ - Mantén el tono solicitado.
40
+ - Si falta información, redacta de forma prudente y general.
41
+
42
+ Devuelve tu respuesta estrictamente en este formato:
43
+
44
+ SUBJECT: ...
45
+ BODY: ...
46
+ """.strip()
YurMail/LLM/types.py ADDED
@@ -0,0 +1,7 @@
1
+ from dataclasses import dataclass
2
+
3
+ @dataclass
4
+ class DraftResult:
5
+ subject: str
6
+ body: str
7
+ raw: str
YurMail/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ from .authentication_microsoft import GraphAuthManager, OAuthCallbackResult
2
+ from .mail import (
3
+ GraphMailClient, GraphAPIError,
4
+ Message, MailAddress, UserProfile, Draft,
5
+ attachment_from_bytes,
6
+ MailTemplate, TemplateRegistry,
7
+ TemplateMissingVariableError, TemplateNotFoundError,
8
+ TEMPLATE_RECORDATORIO, TEMPLATE_SEGUIMIENTO, TEMPLATE_REPORTE, text_to_html,
9
+ )
10
+ from .LLM import OpenAIDraftClient, DraftResult
11
+ from .scheduler import (
12
+ Scheduler,
13
+ AbstractScheduleStore,
14
+ InMemoryStore,
15
+ JsonFileStore,
16
+ ScheduledEmail,
17
+ ScheduleStatus,
18
+ SchedulerResult,
19
+ TriggerType,
20
+ EmailBuilder,
21
+ )
22
+
23
+ __version__ = "0.3.0"
24
+
25
+ __all__ = [
26
+ # Auth
27
+ "GraphAuthManager",
28
+ "OAuthCallbackResult",
29
+ # Mail cliente
30
+ "GraphMailClient",
31
+ "GraphAPIError",
32
+ # Modelos
33
+ "Message",
34
+ "MailAddress",
35
+ "UserProfile",
36
+ "Draft",
37
+ # Adjuntos
38
+ "attachment_from_bytes",
39
+ # Templates
40
+ "MailTemplate",
41
+ "TemplateRegistry",
42
+ "TemplateMissingVariableError",
43
+ "TemplateNotFoundError",
44
+ "TEMPLATE_RECORDATORIO",
45
+ "TEMPLATE_SEGUIMIENTO",
46
+ "TEMPLATE_REPORTE",
47
+ "text_to_html",
48
+ # LLM
49
+ "OpenAIDraftClient",
50
+ "DraftResult",
51
+ # Scheduler
52
+ "Scheduler",
53
+ "AbstractScheduleStore",
54
+ "InMemoryStore",
55
+ "JsonFileStore",
56
+ "ScheduledEmail",
57
+ "ScheduleStatus",
58
+ "SchedulerResult",
59
+ "TriggerType",
60
+ "EmailBuilder",
61
+ ]
@@ -0,0 +1,3 @@
1
+ from .authentication import GraphAuthManager, OAuthCallbackResult
2
+
3
+ __all__ = ["GraphAuthManager", "OAuthCallbackResult"]
@@ -0,0 +1,260 @@
1
+ import asyncio
2
+ import base64
3
+ import hashlib
4
+ import secrets
5
+ import webbrowser
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+ from urllib.parse import urlencode, urlparse
9
+
10
+ import aiohttp
11
+ from aiohttp import web
12
+
13
+
14
+ @dataclass
15
+ class OAuthCallbackResult:
16
+ code: str | None = None
17
+ state: str | None = None
18
+ error: str | None = None
19
+ error_description: str | None = None
20
+
21
+
22
+ class GraphAuthManager:
23
+ """
24
+ Handle Microsoft OAuth 2.0 Authorization Code Flow with PKCE
25
+ for a local/public client.
26
+
27
+ Parameters
28
+
29
+ client_id:
30
+ Application (client) ID from Microsoft Entra app registration.
31
+ tenant:
32
+ Usually "common", "organizations"
33
+ redirect_uri:
34
+ Must exactly match a registered redirect URI, e.g.
35
+ "http://localhost:8765/callback".
36
+ scopes:
37
+ List of scopes such as:
38
+ ["offline_access", "User.Read", "Mail.Send"]
39
+ """
40
+
41
+ def __init__(self, client_id, tenant, redirect_uri, scopes):
42
+ self.client_id = client_id
43
+ self.tenant = tenant
44
+ self.redirect_uri = redirect_uri
45
+ self.scopes = scopes
46
+
47
+ self._authorize_endpoint = (
48
+ f"https://login.microsoftonline.com/{self.tenant}/oauth2/v2.0/authorize"
49
+ )
50
+ self._token_endpoint = (
51
+ f"https://login.microsoftonline.com/{self.tenant}/oauth2/v2.0/token"
52
+ )
53
+
54
+ parsed = urlparse(self.redirect_uri)
55
+ if parsed.scheme not in {"http", "https"}:
56
+ raise ValueError("redirect_uri must start with http:// or https://")
57
+ if not parsed.hostname:
58
+ raise ValueError("redirect_uri must include a hostname")
59
+ if not parsed.port:
60
+ raise ValueError(
61
+ "redirect_uri must include an explicit port for the local callback server"
62
+ )
63
+
64
+ self._redirect_host = parsed.hostname
65
+ self._redirect_port = parsed.port
66
+ self._redirect_path = parsed.path or "/"
67
+
68
+ self._last_state: str | None = None
69
+ self._last_code_verifier: str | None = None
70
+ self._last_token_response: dict[str, Any] | None = None
71
+
72
+ @staticmethod
73
+ def _generate_state() -> str:
74
+ """Generate a random state value for CSRF protection."""
75
+ return secrets.token_urlsafe(32)
76
+
77
+ @staticmethod
78
+ def _generate_code_verifier() -> str:
79
+ """
80
+ Generate a PKCE code verifier.
81
+ RFC 7636 allows 43-128 chars; urlsafe token is convenient here.
82
+ """
83
+ verifier = secrets.token_urlsafe(64)
84
+ return verifier[:128]
85
+
86
+ @staticmethod
87
+ def _generate_code_challenge(code_verifier: str) -> str:
88
+ """Generate the PKCE S256 code challenge from a verifier."""
89
+ digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
90
+ return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
91
+
92
+
93
+ def build_authorization_url(self, state: str) -> str:
94
+ """
95
+ Build the Microsoft authorization URL.
96
+
97
+ This implementation uses PKCE for a public/local client.
98
+ """
99
+ code_verifier = self._generate_code_verifier()
100
+ code_challenge = self._generate_code_challenge(code_verifier)
101
+
102
+ self._last_state = state
103
+ self._last_code_verifier = code_verifier
104
+
105
+ params = {
106
+ "client_id": self.client_id,
107
+ "response_type": "code",
108
+ "redirect_uri": self.redirect_uri,
109
+ "response_mode": "query",
110
+ "scope": " ".join(self.scopes),
111
+ "state": state,
112
+ "code_challenge": code_challenge,
113
+ "code_challenge_method": "S256",
114
+ }
115
+ return f"{self._authorize_endpoint}?{urlencode(params)}"
116
+
117
+ def _build_callback_app(
118
+ self,
119
+ result: OAuthCallbackResult,
120
+ stop_event: asyncio.Event,
121
+ ) -> web.Application:
122
+ """
123
+ Construye una app aiohttp de un solo endpoint para capturar
124
+ el redirect de Microsoft.
125
+
126
+ `stop_event` se activa una vez que se recibe la respuesta,
127
+ permitiendo que `login()` detenga el servidor limpiamente.
128
+ """
129
+ expected_path = self._redirect_path
130
+
131
+ async def callback_handler(request: web.Request) -> web.Response:
132
+ if request.path != expected_path:
133
+ raise web.HTTPNotFound()
134
+
135
+ result.code = request.query.get("code")
136
+ result.state = request.query.get("state")
137
+ result.error = request.query.get("error")
138
+ result.error_description = request.query.get("error_description")
139
+
140
+ stop_event.set()
141
+
142
+ return web.Response(
143
+ content_type="text/html",
144
+ text="""
145
+ <html>
146
+ <body style="font-family: Arial, sans-serif; margin: 2rem;">
147
+ <h2>Autenticacion completada</h2>
148
+ <p>Ya puedes cerrar esta ventana y volver a la terminal.</p>
149
+ </body>
150
+ </html>
151
+ """,
152
+ )
153
+
154
+ app = web.Application()
155
+ app.router.add_get(expected_path, callback_handler)
156
+ # Captura también requests inesperados (e.g. favicon) sin crashear.
157
+ app.router.add_get("/{tail:.*}", lambda r: web.Response(status=204))
158
+ return app
159
+
160
+ async def login(self) -> dict:
161
+ """
162
+ Complete the interactive login flow.
163
+
164
+ Steps:
165
+ 1. Generate state
166
+ 2. Build authorization URL with PKCE
167
+ 3. Start local aiohttp callback server
168
+ 4. Open browser
169
+ 5. Wait for redirect with authorization code
170
+ 6. Exchange code for tokens
171
+ 7. Shut down callback server
172
+ """
173
+ state = self._generate_state()
174
+ auth_url = self.build_authorization_url(state)
175
+
176
+ stop_event = asyncio.Event()
177
+ result = OAuthCallbackResult()
178
+ app = self._build_callback_app(result, stop_event)
179
+
180
+ runner = web.AppRunner(app)
181
+ await runner.setup()
182
+
183
+ site = web.TCPSite(runner, self._redirect_host, self._redirect_port)
184
+ await site.start()
185
+
186
+ webbrowser.open(auth_url)
187
+
188
+ try:
189
+ await asyncio.wait_for(stop_event.wait(), timeout=300)
190
+ except asyncio.TimeoutError:
191
+ raise TimeoutError("Timed out waiting for OAuth callback")
192
+ finally:
193
+ await runner.cleanup()
194
+
195
+ if result.error:
196
+ raise RuntimeError(
197
+ f"Authorization failed: {result.error} - {result.error_description}"
198
+ )
199
+
200
+ if not result.code:
201
+ raise RuntimeError("No authorization code received in callback")
202
+
203
+ if result.state != self._last_state:
204
+ raise RuntimeError("State mismatch in OAuth callback")
205
+
206
+ return await self.exchange_code_for_token(result.code)
207
+
208
+
209
+ async def exchange_code_for_token(self, code: str) -> dict:
210
+ """
211
+ Exchange an authorization code for tokens.
212
+
213
+ Uses PKCE code_verifier instead of a client secret.
214
+ Migrado a aiohttp para no mezclar requests síncronos con asyncio.
215
+ """
216
+ if not self._last_code_verifier:
217
+ raise RuntimeError(
218
+ "No PKCE code verifier available. Call build_authorization_url() first."
219
+ )
220
+
221
+ data = {
222
+ "client_id": self.client_id,
223
+ "grant_type": "authorization_code",
224
+ "code": code,
225
+ "redirect_uri": self.redirect_uri,
226
+ "scope": " ".join(self.scopes),
227
+ "code_verifier": self._last_code_verifier,
228
+ }
229
+
230
+ async with aiohttp.ClientSession() as session:
231
+ async with session.post(
232
+ self._token_endpoint, data=data, timeout=aiohttp.ClientTimeout(total=30)
233
+ ) as response:
234
+ response.raise_for_status()
235
+ token_data = await response.json()
236
+
237
+ self._last_token_response = token_data
238
+ return token_data
239
+
240
+ async def refresh_access_token(self, refresh_token: str) -> dict:
241
+ """
242
+ Use a refresh token to get a new token set.
243
+ Migrado a aiohttp para consistencia async.
244
+ """
245
+ data = {
246
+ "client_id": self.client_id,
247
+ "grant_type": "refresh_token",
248
+ "refresh_token": refresh_token,
249
+ "scope": " ".join(self.scopes),
250
+ }
251
+
252
+ async with aiohttp.ClientSession() as session:
253
+ async with session.post(
254
+ self._token_endpoint, data=data, timeout=aiohttp.ClientTimeout(total=30)
255
+ ) as response:
256
+ response.raise_for_status()
257
+ token_data = await response.json()
258
+
259
+ self._last_token_response = token_data
260
+ return token_data
@@ -0,0 +1,28 @@
1
+ from .client import (
2
+ GraphMailClient, GraphAPIError,
3
+ Message, MailAddress, UserProfile, Draft,
4
+ attachment_from_bytes,
5
+ )
6
+ from .templates import (
7
+ MailTemplate, TemplateRegistry,
8
+ TemplateMissingVariableError, TemplateNotFoundError,
9
+ TEMPLATE_RECORDATORIO, TEMPLATE_SEGUIMIENTO, TEMPLATE_REPORTE, text_to_html,
10
+ )
11
+
12
+ __all__ = [
13
+ "GraphMailClient",
14
+ "GraphAPIError",
15
+ "Message",
16
+ "MailAddress",
17
+ "UserProfile",
18
+ "Draft",
19
+ "attachment_from_bytes",
20
+ "MailTemplate",
21
+ "TemplateRegistry",
22
+ "TemplateMissingVariableError",
23
+ "TemplateNotFoundError",
24
+ "TEMPLATE_RECORDATORIO",
25
+ "TEMPLATE_SEGUIMIENTO",
26
+ "TEMPLATE_REPORTE",
27
+ "text_to_html",
28
+ ]