openmail 0.1.5__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 (67) hide show
  1. openmail/__init__.py +6 -0
  2. openmail/assistants/__init__.py +35 -0
  3. openmail/assistants/classify_emails.py +83 -0
  4. openmail/assistants/compose_email.py +43 -0
  5. openmail/assistants/detect_phishing_for_email.py +61 -0
  6. openmail/assistants/evaluate_sender_trust_for_email.py +59 -0
  7. openmail/assistants/extract_tasks_from_emails.py +126 -0
  8. openmail/assistants/generate_follow_up_for_email.py +54 -0
  9. openmail/assistants/natural_language_query.py +699 -0
  10. openmail/assistants/prioritize_emails.py +89 -0
  11. openmail/assistants/reply.py +58 -0
  12. openmail/assistants/reply_suggestions.py +46 -0
  13. openmail/assistants/rewrite_email.py +50 -0
  14. openmail/assistants/summarize_attachments_for_email.py +101 -0
  15. openmail/assistants/summarize_thread_emails.py +55 -0
  16. openmail/assistants/summary.py +44 -0
  17. openmail/assistants/summary_multi.py +57 -0
  18. openmail/assistants/translate_email.py +54 -0
  19. openmail/auth/__init__.py +6 -0
  20. openmail/auth/base.py +34 -0
  21. openmail/auth/no_auth.py +19 -0
  22. openmail/auth/oauth2.py +58 -0
  23. openmail/auth/password.py +26 -0
  24. openmail/config.py +26 -0
  25. openmail/email_assistant.py +418 -0
  26. openmail/email_manager.py +777 -0
  27. openmail/email_query.py +279 -0
  28. openmail/errors.py +16 -0
  29. openmail/imap/__init__.py +5 -0
  30. openmail/imap/attachment_parts.py +55 -0
  31. openmail/imap/bodystructure.py +296 -0
  32. openmail/imap/client.py +806 -0
  33. openmail/imap/fetch_response.py +115 -0
  34. openmail/imap/inline_cid.py +106 -0
  35. openmail/imap/pagination.py +16 -0
  36. openmail/imap/parser.py +298 -0
  37. openmail/imap/query.py +233 -0
  38. openmail/llm/__init__.py +3 -0
  39. openmail/llm/claude.py +35 -0
  40. openmail/llm/costs.py +108 -0
  41. openmail/llm/gemini.py +34 -0
  42. openmail/llm/gpt.py +33 -0
  43. openmail/llm/groq.py +36 -0
  44. openmail/llm/model.py +126 -0
  45. openmail/llm/xai.py +35 -0
  46. openmail/logger.py +20 -0
  47. openmail/models/__init__.py +20 -0
  48. openmail/models/attachment.py +128 -0
  49. openmail/models/message.py +113 -0
  50. openmail/models/subscription.py +45 -0
  51. openmail/models/task.py +24 -0
  52. openmail/py.typed +0 -0
  53. openmail/smtp/__init__.py +7 -0
  54. openmail/smtp/builder.py +41 -0
  55. openmail/smtp/client.py +218 -0
  56. openmail/smtp/templates.py +16 -0
  57. openmail/subscription/__init__.py +7 -0
  58. openmail/subscription/detector.py +58 -0
  59. openmail/subscription/parser.py +32 -0
  60. openmail/subscription/service.py +237 -0
  61. openmail/types.py +30 -0
  62. openmail/utils/__init__.py +39 -0
  63. openmail/utils/utils.py +295 -0
  64. openmail-0.1.5.dist-info/METADATA +180 -0
  65. openmail-0.1.5.dist-info/RECORD +67 -0
  66. openmail-0.1.5.dist-info/WHEEL +4 -0
  67. openmail-0.1.5.dist-info/licenses/LICENSE +21 -0
openmail/imap/query.py ADDED
@@ -0,0 +1,233 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import List
6
+
7
+
8
+ def _imap_date(iso_yyyy_mm_dd: str) -> str:
9
+ dt = datetime.strptime(iso_yyyy_mm_dd, "%Y-%m-%d")
10
+ return dt.strftime("%d-%b-%Y")
11
+
12
+
13
+ def _q(s: str) -> str:
14
+ """
15
+ Quote/escape a string for IMAP SEARCH.
16
+ """
17
+ s = s.replace("\\", "\\\\").replace('"', r"\"")
18
+ return f'"{s}"'
19
+
20
+
21
+ @dataclass
22
+ class IMAPQuery:
23
+ parts: List[str] = field(default_factory=list)
24
+
25
+ # --- basic fields ---
26
+ def from_(self, s: str) -> IMAPQuery:
27
+ self.parts += ["FROM", _q(s)]
28
+ return self
29
+
30
+ def to(self, s: str) -> IMAPQuery:
31
+ self.parts += ["TO", _q(s)]
32
+ return self
33
+
34
+ def cc(self, s: str) -> IMAPQuery:
35
+ self.parts += ["CC", _q(s)]
36
+ return self
37
+
38
+ def bcc(self, s: str) -> IMAPQuery:
39
+ self.parts += ["BCC", _q(s)]
40
+ return self
41
+
42
+ def subject(self, s: str) -> IMAPQuery:
43
+ self.parts += ["SUBJECT", _q(s)]
44
+ return self
45
+
46
+ def text(self, s: str) -> IMAPQuery:
47
+ """
48
+ Match in headers OR body text.
49
+ """
50
+ self.parts += ["TEXT", _q(s)]
51
+ return self
52
+
53
+ def body(self, s: str) -> IMAPQuery:
54
+ """
55
+ Match only in body text.
56
+ """
57
+ self.parts += ["BODY", _q(s)]
58
+ return self
59
+
60
+ def header(self, name: str, value: str) -> IMAPQuery:
61
+ self.parts += ["HEADER", _q(name), _q(value)]
62
+ return self
63
+
64
+ # --- date filters ---
65
+ def since(self, iso_date: str) -> IMAPQuery:
66
+ self.parts += ["SINCE", _imap_date(iso_date)]
67
+ return self
68
+
69
+ def before(self, iso_date: str) -> IMAPQuery:
70
+ self.parts += ["BEFORE", _imap_date(iso_date)]
71
+ return self
72
+
73
+ def on(self, iso_date: str) -> IMAPQuery:
74
+ self.parts += ["ON", _imap_date(iso_date)]
75
+ return self
76
+
77
+ def sent_since(self, iso_date: str) -> IMAPQuery:
78
+ self.parts += ["SENTSINCE", _imap_date(iso_date)]
79
+ return self
80
+
81
+ def sent_before(self, iso_date: str) -> IMAPQuery:
82
+ self.parts += ["SENTBEFORE", _imap_date(iso_date)]
83
+ return self
84
+
85
+ def sent_on(self, iso_date: str) -> IMAPQuery:
86
+ self.parts += ["SENTON", _imap_date(iso_date)]
87
+ return self
88
+
89
+ # --- flags/status ---
90
+ def seen(self) -> IMAPQuery:
91
+ self.parts += ["SEEN"]
92
+ return self
93
+
94
+ def unseen(self) -> IMAPQuery:
95
+ self.parts += ["UNSEEN"]
96
+ return self
97
+
98
+ def answered(self) -> IMAPQuery:
99
+ self.parts += ["ANSWERED"]
100
+ return self
101
+
102
+ def unanswered(self) -> IMAPQuery:
103
+ self.parts += ["UNANSWERED"]
104
+ return self
105
+
106
+ def flagged(self) -> IMAPQuery:
107
+ self.parts += ["FLAGGED"]
108
+ return self
109
+
110
+ def unflagged(self) -> IMAPQuery:
111
+ self.parts += ["UNFLAGGED"]
112
+ return self
113
+
114
+ def deleted(self) -> IMAPQuery:
115
+ self.parts += ["DELETED"]
116
+ return self
117
+
118
+ def undeleted(self) -> IMAPQuery:
119
+ self.parts += ["UNDELETED"]
120
+ return self
121
+
122
+ def draft(self) -> IMAPQuery:
123
+ self.parts += ["DRAFT"]
124
+ return self
125
+
126
+ def undraft(self) -> IMAPQuery:
127
+ self.parts += ["UNDRAFT"]
128
+ return self
129
+
130
+ def recent(self) -> IMAPQuery:
131
+ self.parts += ["RECENT"]
132
+ return self
133
+
134
+ def new(self) -> IMAPQuery:
135
+ self.parts += ["NEW"]
136
+ return self
137
+
138
+ def old(self) -> IMAPQuery:
139
+ self.parts += ["OLD"]
140
+ return self
141
+
142
+ # --- size ---
143
+ def larger(self, n_bytes: int) -> IMAPQuery:
144
+ self.parts += ["LARGER", str(n_bytes)]
145
+ return self
146
+
147
+ def smaller(self, n_bytes: int) -> IMAPQuery:
148
+ self.parts += ["SMALLER", str(n_bytes)]
149
+ return self
150
+
151
+ # --- keyword ---
152
+ def keyword(self, name: str) -> IMAPQuery:
153
+ self.parts += ["KEYWORD", _q(name)]
154
+ return self
155
+
156
+ def unkeyword(self, name: str) -> IMAPQuery:
157
+ self.parts += ["UNKEYWORD", _q(name)]
158
+ return self
159
+
160
+ # --- UID search ---
161
+ def uid(self, *uids: int | str) -> IMAPQuery:
162
+ """
163
+ Accepts ranges ("1:100") or explicit UIDs (1,2,3)
164
+ """
165
+ joined = ",".join(str(u) for u in uids)
166
+ self.parts += ["UID", joined]
167
+ return self
168
+
169
+ # --- exclude fields ---
170
+ def _not(self, *tokens: str) -> IMAPQuery:
171
+ self.parts += ["NOT", *tokens]
172
+ return self
173
+
174
+ def exclude_from(self, s: str) -> IMAPQuery:
175
+ return self._not("FROM", _q(s))
176
+
177
+ def exclude_to(self, s: str) -> IMAPQuery:
178
+ return self._not("TO", _q(s))
179
+
180
+ def exclude_cc(self, s: str) -> IMAPQuery:
181
+ return self._not("CC", _q(s))
182
+
183
+ def exclude_bcc(self, s: str) -> IMAPQuery:
184
+ return self._not("BCC", _q(s))
185
+
186
+ def exclude_subject(self, s: str) -> IMAPQuery:
187
+ return self._not("SUBJECT", _q(s))
188
+
189
+ def exclude_header(self, name: str, value: str) -> IMAPQuery:
190
+ return self._not("HEADER", _q(name), _q(value))
191
+
192
+ def exclude_text(self, s: str) -> IMAPQuery:
193
+ return self._not("TEXT", _q(s))
194
+
195
+ def exclude_body(self, s: str) -> IMAPQuery:
196
+ return self._not("BODY", _q(s))
197
+
198
+ def or_(self, *queries: IMAPQuery) -> IMAPQuery:
199
+ qs = [q for q in (self, *queries) if q.parts]
200
+
201
+ if len(qs) < 2:
202
+ raise ValueError(
203
+ "or_ requires at least two non-empty IMAPQuery instances (including self)"
204
+ )
205
+
206
+ tokens: List[str] = list(qs[0].parts)
207
+
208
+ for q in qs[1:]:
209
+ right = list(q.parts)
210
+
211
+ tokens = ["OR", "("] + tokens + [")", "("] + right + [")"]
212
+
213
+ self.parts = tokens
214
+ return self
215
+
216
+ # --- composition helpers ---
217
+ def all(self) -> IMAPQuery:
218
+ self.parts += ["ALL"]
219
+ return self
220
+
221
+ def raw(self, *tokens: str) -> IMAPQuery:
222
+ """
223
+ Append raw tokens for advanced users, e.g. raw("OR", 'FROM "a"', 'FROM "b"')
224
+ """
225
+ self.parts += list(tokens)
226
+ return self
227
+
228
+ def build(self) -> str:
229
+ if not self.parts:
230
+ return "ALL"
231
+ s = " ".join(self.parts)
232
+ s = s.replace("( ", "(").replace(" )", ")")
233
+ return s
@@ -0,0 +1,3 @@
1
+ from openmail.llm.model import get_model
2
+
3
+ __all__ = ["get_model"]
openmail/llm/claude.py ADDED
@@ -0,0 +1,35 @@
1
+ from typing import Type
2
+
3
+ from langchain_anthropic import ChatAnthropic
4
+ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
5
+ from pydantic import BaseModel
6
+
7
+
8
+ def get_claude(
9
+ model_name: str,
10
+ pydantic_model: Type[BaseModel],
11
+ temperature: float = 0.1,
12
+ timeout: int = 120,
13
+ ):
14
+
15
+ base_llm = ChatAnthropic(
16
+ model=model_name,
17
+ temperature=temperature,
18
+ timeout=timeout,
19
+ )
20
+
21
+ llm_structured = base_llm.with_structured_output(pydantic_model)
22
+
23
+ base_prompt = ChatPromptTemplate.from_messages(
24
+ [
25
+ ("system", "Return ONLY valid JSON that matches the required schema. No extra text."),
26
+ MessagesPlaceholder("messages"),
27
+ ]
28
+ )
29
+
30
+ chain = base_prompt | llm_structured
31
+
32
+ if chain is None:
33
+ raise RuntimeError("LLM not available for the given model_name")
34
+
35
+ return chain
openmail/llm/costs.py ADDED
@@ -0,0 +1,108 @@
1
+ from typing import Dict
2
+
3
+ from langchain_core.callbacks import BaseCallbackHandler
4
+ from langchain_core.outputs import LLMResult
5
+
6
+ OPENAI_PRICES_PER_1M: Dict[str, Dict[str, float]] = {
7
+ "gpt-5-mini": {"input": 0.25, "cached_input": 0.025, "output": 2.00},
8
+ "gpt-5-nano": {"input": 0.05, "cached_input": 0.005, "output": 0.40},
9
+ "gpt-5.2": {"input": 1.75, "cached_input": 0.175, "output": 14.00},
10
+ "gpt-4o": {"input": 2.50, "cached_input": 1.25, "output": 10.00},
11
+ "gpt-4o-mini": {"input": 0.15, "cached_input": 0.075, "output": 0.60},
12
+ }
13
+
14
+ XAI_PRICES_PER_1M: Dict[str, Dict[str, float]] = {
15
+ "grok-4-1-fast-reasoning": {"input": 0.20, "cached_input": 0.05, "output": 0.50},
16
+ "grok-4-1-fast-non-reasoning": {"input": 0.20, "cached_input": 0.05, "output": 0.50},
17
+ "grok-4": {"input": 3.00, "cached_input": 0.75, "output": 15.00},
18
+ "grok-4-fast-reasoning": {"input": 0.20, "cached_input": 0.05, "output": 0.50},
19
+ "grok-4-fast-non-reasoning": {"input": 0.20, "cached_input": 0.05, "output": 0.50},
20
+ "grok-3-mini": {"input": 0.30, "cached_input": 0.075, "output": 0.50},
21
+ "grok-3": {"input": 3.00, "cached_input": 0.75, "output": 15.00},
22
+ }
23
+ GROQ_PRICES_PER_1M: Dict[str, Dict[str, float]] = {
24
+ "openai/gpt-oss-20b": {"input": 0.075, "cached_input": 0.037, "output": 0.30},
25
+ "openai/gpt-oss-120b": {"input": 0.15, "cached_input": 0.075, "output": 0.60},
26
+ "moonshotai/kimi-k2-instruct-0905": {"input": 1.00, "cached_input": 0.50, "output": 3.00},
27
+ "meta-llama/llama-4-scout-17b-16e-instruct": {
28
+ "input": 0.11,
29
+ "cached_input": 0.00,
30
+ "output": 0.34,
31
+ },
32
+ "meta-llama/llama-4-maverick-17b-128e-instruct": {
33
+ "input": 0.20,
34
+ "cached_input": 0.00,
35
+ "output": 0.60,
36
+ },
37
+ "qwen/qwen3-32b": {"input": 0.29, "cached_input": 0.00, "output": 0.59},
38
+ "llama-3.1-8b-instant": {"input": 0.05, "cached_input": 0.00, "output": 0.08},
39
+ }
40
+
41
+ GEMINI_PRICES_PER_1M: Dict[str, Dict[str, float]] = {
42
+ "gemini-3-flash-preview": {"input": 0.50, "cached_input": 0.05, "output": 3.00},
43
+ "gemini-2.5-flash": {"input": 0.30, "cached_input": 0.03, "output": 2.50},
44
+ "gemini-2.5-flash-lite": {"input": 0.10, "cached_input": 0.01, "output": 0.40},
45
+ }
46
+
47
+ CLAUDE_PRICES_PER_1M: Dict[str, Dict[str, float]] = {
48
+ "claude-opus-4.5": {"input": 5.00, "cached_input": 0.50, "output": 25.00},
49
+ "claude-opus-4.1": {"input": 15.00, "cached_input": 1.50, "output": 75.00},
50
+ "claude-opus-4": {"input": 15.00, "cached_input": 1.50, "output": 75.00},
51
+ "claude-sonnet-4.5": {"input": 3.00, "cached_input": 0.30, "output": 15.00},
52
+ "claude-sonnet-4": {"input": 3.00, "cached_input": 0.30, "output": 15.00},
53
+ "claude-haiku-4.5": {"input": 1.00, "cached_input": 0.10, "output": 5.00},
54
+ "claude-haiku-3.5": {"input": 0.80, "cached_input": 0.08, "output": 4.00},
55
+ "claude-haiku-3": {"input": 0.25, "cached_input": 0.03, "output": 1.25},
56
+ }
57
+
58
+
59
+ class TokenUsageCallback(BaseCallbackHandler):
60
+ """Collect token usage from a single LLM call."""
61
+
62
+ def __init__(self) -> None:
63
+ self.prompt_tokens = 0
64
+ self.completion_tokens = 0
65
+ self.total_tokens = 0
66
+ self.cached_prompt_tokens = 0
67
+
68
+ def on_llm_end(self, response: LLMResult, **kwargs) -> None:
69
+
70
+ usage = (response.llm_output or {}).get("token_usage")
71
+ if usage:
72
+ self.prompt_tokens += usage.get("prompt_tokens", 0)
73
+ self.completion_tokens += usage.get("completion_tokens", 0)
74
+ self.total_tokens += usage.get("total_tokens", 0)
75
+
76
+ prompt_details = usage.get("prompt_tokens_details") or {}
77
+ self.cached_prompt_tokens += prompt_details.get("cached_tokens", 0)
78
+ return
79
+
80
+
81
+ def _lookup_price(provider: str, model_name: str) -> Dict[str, float]:
82
+ if provider == "openai":
83
+ return OPENAI_PRICES_PER_1M[model_name]
84
+ if provider == "gemini":
85
+ return GEMINI_PRICES_PER_1M[model_name]
86
+ if provider == "xai":
87
+ return XAI_PRICES_PER_1M[model_name]
88
+ if provider == "groq":
89
+ return GROQ_PRICES_PER_1M[model_name]
90
+ if provider == "claude":
91
+ return CLAUDE_PRICES_PER_1M[model_name]
92
+
93
+ return {"input": 0.0, "cached_input": 0.0, "output": 0.0}
94
+
95
+
96
+ def compute_cost_usd(
97
+ provider: str,
98
+ model_name: str,
99
+ prompt_tokens: int,
100
+ completion_tokens: int,
101
+ cached_prompt_tokens: int = 0,
102
+ ) -> float:
103
+ prices = _lookup_price(provider, model_name)
104
+ return (
105
+ ((prompt_tokens - cached_prompt_tokens) / 1000000.0) * prices["input"]
106
+ + (cached_prompt_tokens / 1000000.0) * prices["cached_input"]
107
+ + (completion_tokens / 1000000.0) * prices["output"]
108
+ )
openmail/llm/gemini.py ADDED
@@ -0,0 +1,34 @@
1
+ from typing import Type
2
+
3
+ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
4
+ from langchain_google_genai import ChatGoogleGenerativeAI
5
+ from pydantic import BaseModel
6
+
7
+
8
+ def get_gemini(
9
+ model_name: str,
10
+ pydantic_model: Type[BaseModel],
11
+ temperature: float = 0.1,
12
+ timeout: int = 120,
13
+ ):
14
+
15
+ base_llm = ChatGoogleGenerativeAI(
16
+ model=model_name,
17
+ temperature=temperature,
18
+ timeout=timeout,
19
+ convert_system_message_to_human=True,
20
+ )
21
+
22
+ llm_structured = base_llm.with_structured_output(pydantic_model)
23
+ base_prompt = ChatPromptTemplate.from_messages(
24
+ [
25
+ ("system", "Return ONLY valid JSON that matches the required schema. No extra text."),
26
+ MessagesPlaceholder("messages"),
27
+ ]
28
+ )
29
+ chain = base_prompt | llm_structured
30
+
31
+ if chain is None:
32
+ raise RuntimeError("LLM not available for the given model_name")
33
+
34
+ return chain
openmail/llm/gpt.py ADDED
@@ -0,0 +1,33 @@
1
+ from typing import Type
2
+
3
+ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
4
+ from langchain_openai import ChatOpenAI
5
+ from pydantic import BaseModel
6
+
7
+
8
+ def get_openai(
9
+ model_name: str,
10
+ pydantic_model: Type[BaseModel],
11
+ temperature: float = 0.1,
12
+ timeout: int = 120,
13
+ ):
14
+
15
+ base_llm = ChatOpenAI(
16
+ model=model_name,
17
+ temperature=temperature,
18
+ timeout=timeout,
19
+ )
20
+
21
+ llm_structured = base_llm.with_structured_output(pydantic_model)
22
+ base_prompt = ChatPromptTemplate.from_messages(
23
+ [
24
+ ("system", "Return ONLY valid JSON that matches the required schema. No extra text."),
25
+ MessagesPlaceholder("messages"),
26
+ ]
27
+ )
28
+ chain = base_prompt | llm_structured
29
+
30
+ if chain is None:
31
+ raise RuntimeError("LLM not available for the given model_name")
32
+
33
+ return chain
openmail/llm/groq.py ADDED
@@ -0,0 +1,36 @@
1
+ from typing import Type
2
+
3
+ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
4
+ from langchain_groq import ChatGroq
5
+ from pydantic import BaseModel
6
+
7
+
8
+ def get_groq(
9
+ model_name: str,
10
+ pydantic_model: Type[BaseModel],
11
+ temperature: float = 0.1,
12
+ timeout: int = 120,
13
+ ):
14
+
15
+ base_llm = ChatGroq(
16
+ model=model_name,
17
+ temperature=temperature,
18
+ timeout=timeout,
19
+ )
20
+
21
+ llm_structured = base_llm.with_structured_output(
22
+ pydantic_model,
23
+ method="function_calling",
24
+ )
25
+ base_prompt = ChatPromptTemplate.from_messages(
26
+ [
27
+ ("system", "Return ONLY valid JSON that matches the required schema. No extra text."),
28
+ MessagesPlaceholder("messages"),
29
+ ]
30
+ )
31
+ chain = base_prompt | llm_structured
32
+
33
+ if chain is None:
34
+ raise RuntimeError("LLM not available for the given model_name")
35
+
36
+ return chain
openmail/llm/model.py ADDED
@@ -0,0 +1,126 @@
1
+ import time
2
+ from functools import lru_cache
3
+ from json import JSONDecodeError
4
+ from random import random
5
+ from time import sleep
6
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Type, TypeVar
7
+
8
+ from langchain_core.exceptions import OutputParserException
9
+ from openai import APIConnectionError, APITimeoutError, RateLimitError
10
+ from pydantic import BaseModel, ValidationError
11
+
12
+ from openmail.llm.claude import get_claude
13
+ from openmail.llm.costs import TokenUsageCallback, compute_cost_usd
14
+ from openmail.llm.gemini import get_gemini
15
+ from openmail.llm.gpt import get_openai
16
+ from openmail.llm.groq import get_groq
17
+ from openmail.llm.xai import get_xai
18
+
19
+ TModel = TypeVar("TModel", bound=BaseModel)
20
+
21
+
22
+ @lru_cache(maxsize=None)
23
+ def _get_base_llm(
24
+ provider: str,
25
+ model_name: str,
26
+ pydantic_model: Type[TModel],
27
+ temperature: float = 0.1,
28
+ timeout: int = 120,
29
+ ):
30
+ if provider == "openai":
31
+ return get_openai(model_name, pydantic_model, temperature, timeout)
32
+ if provider == "gemini":
33
+ return get_gemini(model_name, pydantic_model, temperature, timeout)
34
+ if provider == "xai":
35
+ return get_xai(model_name, pydantic_model, temperature, timeout)
36
+ if provider == "groq":
37
+ return get_groq(model_name, pydantic_model, temperature, timeout)
38
+ if provider == "claude":
39
+ return get_claude(model_name, pydantic_model, temperature, timeout)
40
+
41
+ raise RuntimeError("LLM not available for the given model_name")
42
+
43
+
44
+ def get_model(
45
+ provider: str,
46
+ model_name: str,
47
+ pydantic_model: Type[TModel],
48
+ temperature: float = 0.1,
49
+ retries: int = 5, # -1 for infinite retries
50
+ base_delay: float = 1.5,
51
+ max_delay: float = 30.0,
52
+ timeout: int = 120,
53
+ all_fail_raise: bool = True,
54
+ ) -> Callable[[str], Tuple[TModel, Dict[str, Any]]]:
55
+
56
+ chain = _get_base_llm(provider, model_name, pydantic_model, temperature, timeout)
57
+
58
+ TRANSIENT_EXC = (APIConnectionError, APITimeoutError, RateLimitError)
59
+ PARSE_EXC = (JSONDecodeError, OutputParserException, ValidationError)
60
+
61
+ history: List[Dict[str, str]] = []
62
+
63
+ def run(prompt_text: str) -> Tuple[TModel, Dict[str, Any]]:
64
+ nonlocal history
65
+ infinite = retries == -1
66
+ max_tries = float("inf") if infinite else max(1, retries)
67
+ delay = base_delay
68
+ last_exc: Optional[BaseException] = None
69
+
70
+ attempt = 0
71
+ while attempt < max_tries:
72
+ try:
73
+ messages = history + [
74
+ {"role": "user", "content": prompt_text},
75
+ ]
76
+
77
+ usage_cb = TokenUsageCallback()
78
+ t0 = time.perf_counter()
79
+ out = chain.invoke(
80
+ {"messages": messages},
81
+ config={"callbacks": [usage_cb]},
82
+ )
83
+ t1 = time.perf_counter()
84
+
85
+ model_obj = (
86
+ out if isinstance(out, BaseModel) else pydantic_model.model_validate(out)
87
+ )
88
+ out_dict = model_obj.model_dump()
89
+
90
+ history = messages + [{"role": "assistant", "content": model_obj.model_dump_json()}]
91
+
92
+ cost_usd = compute_cost_usd(
93
+ provider,
94
+ model_name,
95
+ prompt_tokens=usage_cb.prompt_tokens,
96
+ completion_tokens=usage_cb.completion_tokens,
97
+ cached_prompt_tokens=usage_cb.cached_prompt_tokens,
98
+ )
99
+ llm_call_info = {
100
+ "model": model_name,
101
+ "prompt": prompt_text,
102
+ "response": out_dict,
103
+ "call_time": t1 - t0,
104
+ "timestamp": time.strftime("%m/%d/%Y_%H:%M:%S"),
105
+ "prompt_tokens": usage_cb.prompt_tokens,
106
+ "completion_tokens": usage_cb.completion_tokens,
107
+ "cached_prompt_tokens": usage_cb.cached_prompt_tokens,
108
+ "cost_usd": cost_usd,
109
+ }
110
+
111
+ return model_obj, llm_call_info
112
+
113
+ except (*TRANSIENT_EXC, *PARSE_EXC) as e:
114
+ last_exc = e
115
+ except Exception as e:
116
+ last_exc = e
117
+
118
+ attempt += 1
119
+ if not infinite and attempt >= max_tries:
120
+ if all_fail_raise and last_exc is not None:
121
+ raise last_exc
122
+
123
+ sleep(delay + random() * 0.5)
124
+ delay = min(delay * 2, max_delay)
125
+
126
+ return run
openmail/llm/xai.py ADDED
@@ -0,0 +1,35 @@
1
+ from typing import Type
2
+
3
+ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
4
+ from langchain_xai import ChatXAI
5
+ from pydantic import BaseModel
6
+
7
+
8
+ def get_xai(
9
+ model_name: str,
10
+ pydantic_model: Type[BaseModel],
11
+ temperature: float = 0.1,
12
+ timeout: int = 120,
13
+ ):
14
+
15
+ base_llm = ChatXAI(
16
+ model=model_name,
17
+ temperature=temperature,
18
+ timeout=timeout,
19
+ )
20
+
21
+ llm_structured = base_llm.with_structured_output(pydantic_model)
22
+
23
+ base_prompt = ChatPromptTemplate.from_messages(
24
+ [
25
+ ("system", "Return ONLY valid JSON that matches the required schema. No extra text."),
26
+ MessagesPlaceholder("messages"),
27
+ ]
28
+ )
29
+
30
+ chain = base_prompt | llm_structured
31
+
32
+ if chain is None:
33
+ raise RuntimeError("LLM not available for the given model_name")
34
+
35
+ return chain
openmail/logger.py ADDED
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ LOGGER_NAME = "email_manager"
6
+
7
+
8
+ def get_logger(name: str = LOGGER_NAME) -> logging.Logger:
9
+ return logging.getLogger(name)
10
+
11
+
12
+ def configure_logging(level: int = logging.INFO) -> None:
13
+ logger = get_logger()
14
+ if logger.handlers:
15
+ return
16
+ handler = logging.StreamHandler()
17
+ formatter = logging.Formatter("%(asctime)s %(levelname)s [%(name)s] %(message)s")
18
+ handler.setFormatter(formatter)
19
+ logger.addHandler(handler)
20
+ logger.setLevel(level)
@@ -0,0 +1,20 @@
1
+ from openmail.models.attachment import Attachment, AttachmentMeta
2
+ from openmail.models.message import EmailAddress, EmailMessage, EmailOverview
3
+ from openmail.models.subscription import (
4
+ UnsubscribeActionResult,
5
+ UnsubscribeCandidate,
6
+ UnsubscribeMethod,
7
+ )
8
+ from openmail.models.task import Task
9
+
10
+ __all__ = [
11
+ "EmailAddress",
12
+ "EmailMessage",
13
+ "EmailOverview",
14
+ "AttachmentMeta",
15
+ "Attachment",
16
+ "UnsubscribeMethod",
17
+ "UnsubscribeCandidate",
18
+ "UnsubscribeActionResult",
19
+ "Task",
20
+ ]