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.
- YurMail/LLM/__init__.py +4 -0
- YurMail/LLM/client.py +73 -0
- YurMail/LLM/prompts.py +46 -0
- YurMail/LLM/types.py +7 -0
- YurMail/__init__.py +61 -0
- YurMail/authentication_microsoft/__init__.py +3 -0
- YurMail/authentication_microsoft/authentication.py +260 -0
- YurMail/mail/__init__.py +28 -0
- YurMail/mail/client.py +505 -0
- YurMail/mail/templates.py +338 -0
- YurMail/scheduler/__init__.py +19 -0
- YurMail/scheduler/builder.py +182 -0
- YurMail/scheduler/scheduler.py +353 -0
- YurMail/scheduler/store.py +205 -0
- YurMail/scheduler/types.py +128 -0
- yurmail-0.1.0.dist-info/METADATA +308 -0
- yurmail-0.1.0.dist-info/RECORD +19 -0
- yurmail-0.1.0.dist-info/WHEEL +5 -0
- yurmail-0.1.0.dist-info/top_level.txt +1 -0
YurMail/LLM/__init__.py
ADDED
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
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,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
|
YurMail/mail/__init__.py
ADDED
|
@@ -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
|
+
]
|