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
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List, Sequence, Tuple
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from openmail.llm import get_model
8
+ from openmail.models import EmailMessage
9
+ from openmail.utils import build_email_context
10
+
11
+ PRIORITIZE_EMAILS_PROMPT = """
12
+ You are an assistant that assigns a priority score to each email.
13
+
14
+ Consider:
15
+ - Urgency and explicit deadlines.
16
+ - Importance of the sender (e.g., manager, key client vs. newsletter).
17
+ - Clear requests for action or decision.
18
+ - Relevance to ongoing work or commitments.
19
+
20
+ Instructions:
21
+ - Each email is identified by an Email ID.
22
+ - For each email, you must output:
23
+ - id: the Email ID (copied exactly as given)
24
+ - score: a numeric priority score between 0.0 and 1.0
25
+ - 1.0 = extremely urgent and important
26
+ - 0.0 = trivial / ignorable
27
+ - Base the score only on the email content and metadata provided.
28
+ - Do not omit any email; produce one item for every Email ID.
29
+
30
+ Emails:
31
+ {email_blocks}
32
+ """
33
+
34
+
35
+ class EmailPriorityItem(BaseModel):
36
+ id: str = Field(
37
+ description="Opaque ID that identifies one email exactly as given in the prompt."
38
+ )
39
+ score: float = Field(
40
+ ge=0.0,
41
+ le=1.0,
42
+ description="Priority score between 0.0 (trivial) and 1.0 (critical).",
43
+ )
44
+
45
+
46
+ class EmailPrioritySchema(BaseModel):
47
+ items: List[EmailPriorityItem] = Field(
48
+ description=(
49
+ "List of priority results. Each item contains an email id and a score. "
50
+ "There must be one item per email."
51
+ )
52
+ )
53
+
54
+
55
+ def llm_prioritize_emails(
56
+ messages: Sequence[EmailMessage],
57
+ *,
58
+ provider: str,
59
+ model_name: str,
60
+ ) -> Tuple[List[float], Dict[str, Any]]:
61
+ """
62
+ Assign a priority score to multiple emails at once.
63
+ """
64
+ if not messages:
65
+ return [], {}
66
+
67
+ id_list = [f"e{i + 1}" for i in range(len(messages))]
68
+ id_to_index = {id_: i for i, id_ in enumerate(id_list)}
69
+
70
+ email_blocks = "\n\n".join(
71
+ f"Email ID: {email_id}\n{build_email_context(msg)}"
72
+ for email_id, msg in zip(id_list, messages)
73
+ )
74
+
75
+ prompt = PRIORITIZE_EMAILS_PROMPT.format(
76
+ email_blocks=email_blocks,
77
+ )
78
+
79
+ chain = get_model(provider, model_name, EmailPrioritySchema)
80
+ result, llm_call_info = chain(prompt)
81
+
82
+ # Preserve order of input messages
83
+ scores: List[float] = [0.0] * len(messages)
84
+ for item in result.items:
85
+ idx = id_to_index.get(item.id)
86
+ if idx is not None:
87
+ scores[idx] = item.score
88
+
89
+ return scores, llm_call_info
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional, Tuple
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from openmail.llm import get_model
8
+ from openmail.models import EmailMessage
9
+ from openmail.utils import build_email_context
10
+
11
+ EMAIL_REPLY_PROMPT = """
12
+ You are an assistant that drafts concise, polite email replies.
13
+
14
+ The previous suggested reply (for reference or editing):
15
+ {previous_reply}
16
+
17
+ The user's instruction about how to change or generate the reply:
18
+ {reply_context}
19
+
20
+ Instructions (follow all):
21
+ - Either improve/refine the previous reply, or write a new one if needed.
22
+ - Follow the user's instruction above as much as possible.
23
+ - Be professional but friendly.
24
+ - Keep it short and to the point.
25
+ - Do NOT explain what you are doing.
26
+ - Output ONLY the email reply body text (no surrounding quotes).
27
+
28
+ Email context:
29
+ {email_context}
30
+ """
31
+
32
+
33
+ class EmailReplySchema(BaseModel):
34
+ reply: str = Field(description="A concise reply body for the email.")
35
+
36
+
37
+ def llm_concise_reply_for_email(
38
+ reply_context: str,
39
+ msg: EmailMessage,
40
+ *,
41
+ provider: str,
42
+ model_name: str,
43
+ previous_reply: Optional[str] = None,
44
+ ) -> Tuple[str, dict[str, Any]]:
45
+ """
46
+ Generate a concise email reply using the LLM pipeline.
47
+ """
48
+ chain = get_model(provider, model_name, EmailReplySchema)
49
+ email_context = build_email_context(msg)
50
+ result, llm_call_info = chain(
51
+ EMAIL_REPLY_PROMPT.format(
52
+ previous_reply=previous_reply or "",
53
+ reply_context=reply_context,
54
+ email_context=email_context,
55
+ )
56
+ )
57
+
58
+ return result.reply, llm_call_info
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, List, Tuple
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from openmail.llm import get_model
8
+ from openmail.models import EmailMessage
9
+ from openmail.utils import build_email_context
10
+
11
+ EMAIL_REPLY_SUGGESTION_PROMPT = """
12
+ You are an assistant that generates brief suggestions for how a user could reply to an email.
13
+
14
+ Instructions:
15
+ - Provide 2 to 3 distinct reply strategies.
16
+ - Each suggestion must be short (4 to 10 words).
17
+ - Describe the intent of the reply, NOT the reply text itself.
18
+ - No numbering, no bullets, no quotes, no commentary.
19
+ - Separate each suggestion with a blank line.
20
+
21
+ Email context:
22
+ {email_context}
23
+ """
24
+
25
+
26
+ class EmailReplySuggestionsSchema(BaseModel):
27
+ suggestions: List[str] = Field(description="2-3 concise suggestions describing how to reply.")
28
+
29
+
30
+ def llm_reply_suggestions_for_email(
31
+ msg: EmailMessage,
32
+ *,
33
+ provider: str,
34
+ model_name: str,
35
+ ) -> Tuple[List[str], dict[str, Any]]:
36
+ """
37
+ Generate 2-3 short reply suggestions using the LLM pipeline.
38
+ """
39
+ chain = get_model(provider, model_name, EmailReplySuggestionsSchema)
40
+ email_context = build_email_context(msg)
41
+ result, llm_call_info = chain(
42
+ EMAIL_REPLY_SUGGESTION_PROMPT.format(
43
+ email_context=email_context,
44
+ )
45
+ )
46
+ return result.suggestions, llm_call_info
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Tuple
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from openmail.llm import get_model
8
+
9
+ REWRITE_EMAIL_PROMPT = """
10
+ You are an assistant that rewrites email drafts while preserving the original meaning.
11
+
12
+ Instructions:
13
+ - Rewrite the email using the requested style.
14
+ - Preserve all important facts, commitments, and dates.
15
+ - Keep the structure of an email (greeting, body, closing) if present.
16
+ - Do not add new information that is not implied by the original.
17
+ - Do not include any explanation; only output the rewritten email text.
18
+
19
+ Requested style:
20
+ {style}
21
+
22
+ Original email:
23
+ {draft}
24
+ """
25
+
26
+
27
+ class RewriteEmailSchema(BaseModel):
28
+ rewritten_email: str = Field(
29
+ description="The full email text rewritten in the requested style."
30
+ )
31
+
32
+
33
+ def llm_rewrite_email(
34
+ draft_text: str,
35
+ style: str,
36
+ *,
37
+ provider: str,
38
+ model_name: str,
39
+ ) -> Tuple[str, dict[str, Any]]:
40
+ """
41
+ Rewrite an email draft according to a requested style.
42
+ """
43
+ chain = get_model(provider, model_name, RewriteEmailSchema)
44
+ result, llm_call_info = chain(
45
+ REWRITE_EMAIL_PROMPT.format(
46
+ style=style,
47
+ draft=draft_text,
48
+ )
49
+ )
50
+ return result.rewritten_email, llm_call_info
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List, Tuple
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from openmail.llm import get_model
8
+ from openmail.models import EmailMessage
9
+ from openmail.utils import looks_binary, safe_decode
10
+
11
+ ATTACHMENT_SUMMARY_PROMPT = """
12
+ You are an assistant that summarizes file attachments from an email.
13
+
14
+ Instructions:
15
+ - For each attachment, provide a concise summary (3-6 sentences).
16
+ - Focus on key points, decisions, and any important data.
17
+ - Do not copy large passages verbatim.
18
+ - If the content is not meaningful (e.g. very short or empty), say so briefly.
19
+
20
+ Below are the attachments with their content.
21
+
22
+ Attachments:
23
+ {attachments_context}
24
+ """
25
+
26
+
27
+ class AttachmentSummarySchema(BaseModel):
28
+ filename: str = Field(description="Filename of the attachment.")
29
+ summary: str = Field(description="Concise summary of the attachment's contents.")
30
+
31
+
32
+ class AttachmentSummariesSchema(BaseModel):
33
+ attachments: List[AttachmentSummarySchema] = Field(
34
+ description="List of summaries for each attachment."
35
+ )
36
+
37
+
38
+ def _build_attachments_context(message: EmailMessage) -> str:
39
+ """
40
+ Build a text context representing all attachments.
41
+
42
+ Adjust attribute names here to match your actual attachment model.
43
+ """
44
+
45
+ attachments = message.attachments
46
+ parts: List[str] = []
47
+
48
+ for idx, att in enumerate(attachments, start=1):
49
+ filename = att.filename
50
+ # Try common attributes for text content
51
+ data = att.data
52
+ decoded = safe_decode(data)
53
+
54
+ if not decoded:
55
+ parts.append(
56
+ f"--- Attachment #{idx} ---\n"
57
+ f"Filename: {filename}\n"
58
+ f"Content: [non-text or empty bytes]\n"
59
+ )
60
+ continue
61
+
62
+ if looks_binary(decoded):
63
+ parts.append(
64
+ f"--- Attachment #{idx} ---\n"
65
+ f"Filename: {filename}\n"
66
+ f"Content: [binary data - not summarized]\n"
67
+ )
68
+ continue
69
+
70
+ if len(decoded) > 4000:
71
+ decoded = decoded[:4000] + "\n...[truncated]..."
72
+
73
+ parts.append(
74
+ f"--- Attachment #{idx} ---\n" f"Filename: {filename}\n" f"Content:\n{decoded}\n"
75
+ )
76
+
77
+ return "\n".join(parts)
78
+
79
+
80
+ def llm_summarize_attachments_for_email(
81
+ message: EmailMessage,
82
+ *,
83
+ provider: str,
84
+ model_name: str,
85
+ ) -> Tuple[Dict[str, str], dict[str, Any]]:
86
+ """
87
+ Summarize each attachment in an email.
88
+ """
89
+ attachments = message.attachments
90
+ if not attachments:
91
+ return {}, {}
92
+
93
+ attachments_context = _build_attachments_context(message)
94
+
95
+ chain = get_model(provider, model_name, AttachmentSummariesSchema)
96
+ result, llm_call_info = chain(
97
+ ATTACHMENT_SUMMARY_PROMPT.format(attachments_context=attachments_context)
98
+ )
99
+
100
+ summaries: Dict[str, str] = {att.filename: att.summary for att in result.attachments}
101
+ return summaries, llm_call_info
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, List, Sequence, Tuple
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from openmail.llm import get_model
8
+ from openmail.models import EmailMessage
9
+ from openmail.utils import build_email_context
10
+
11
+ THREAD_SUMMARY_PROMPT = """
12
+ You are an assistant that summarizes email conversation threads.
13
+
14
+ Instructions:
15
+ - Read the entire thread carefully.
16
+ - Provide a concise summary (5-10 sentences).
17
+ - Focus on:
18
+ - Overall context and purpose of the thread.
19
+ - Key decisions that have been made.
20
+ - Open questions that still need answers.
21
+ - Action items and who is responsible for them (if specified).
22
+ - Write in clear, professional language.
23
+ - Do not include greetings or sign-offs.
24
+
25
+ Email thread (oldest to newest):
26
+ {thread_context}
27
+ """
28
+
29
+
30
+ class ThreadSummarySchema(BaseModel):
31
+ summary: str = Field(
32
+ description="Concise summary of the email thread, including context, decisions, open questions, and action items."
33
+ )
34
+
35
+
36
+ def llm_summarize_thread_emails(
37
+ messages: Sequence[EmailMessage],
38
+ *,
39
+ provider: str,
40
+ model_name: str,
41
+ ) -> Tuple[str, dict[str, Any]]:
42
+ """
43
+ Summarize a sequence of emails representing a thread.
44
+ """
45
+
46
+ parts: List[str] = []
47
+ for idx, msg in enumerate(messages, start=1):
48
+ ctx = build_email_context(msg)
49
+ parts.append(f"--- Email #{idx} ---\n{ctx}\n")
50
+
51
+ thread_context = "\n".join(parts)
52
+
53
+ chain = get_model(provider, model_name, ThreadSummarySchema)
54
+ result, llm_call_info = chain(THREAD_SUMMARY_PROMPT.format(thread_context=thread_context))
55
+ return result.summary, llm_call_info
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Tuple
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from openmail.llm import get_model
8
+ from openmail.models import EmailMessage
9
+ from openmail.utils import build_email_context
10
+
11
+ EMAIL_SUMMARY_PROMPT = """
12
+ You are an assistant that summarizes emails for a busy user.
13
+
14
+ Instructions (follow all):
15
+ - Summarize the email in 2-4 sentences.
16
+ - Capture the main points, action items, and any dates/deadlines.
17
+ - Do NOT include any meta commentary, just the summary text.
18
+
19
+ Email context:
20
+ {email_context}
21
+ """
22
+
23
+
24
+ class EmailSummarySchema(BaseModel):
25
+ summary: str = Field(description="A concise summary of a single email.")
26
+
27
+
28
+ def llm_summarize_single_email(
29
+ msg: EmailMessage,
30
+ *,
31
+ provider: str,
32
+ model_name: str,
33
+ ) -> Tuple[str, Dict[str, Any]]:
34
+ """
35
+ Generate a concise email reply using the LLM pipeline.
36
+ """
37
+ chain = get_model(provider, model_name, EmailSummarySchema)
38
+ email_context = build_email_context(msg)
39
+ result, llm_call_info = chain(
40
+ EMAIL_SUMMARY_PROMPT.format(
41
+ email_context=email_context,
42
+ )
43
+ )
44
+ return result.summary, llm_call_info
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from openmail.llm import get_model
8
+ from openmail.models import EmailMessage
9
+ from openmail.utils import build_email_context
10
+
11
+ EMAIL_MULTI_SUMMARY_PROMPT = """
12
+ You are helping a user triage multiple emails.
13
+
14
+ You will see several emails, each labeled with a UID.
15
+
16
+ Instructions (follow all):
17
+ - Write ONE short paragraph.
18
+ - Clearly state which emails are IMPORTANT or URGENT.
19
+ - Briefly mention the other emails as less important.
20
+ - When referring to an IMPORTANT email, you MUST mention its UID exactly
21
+ as 'UID=<number>' so the UI can link directly to it.
22
+ - Keep the paragraph compact and easy to scan.
23
+
24
+ Emails:
25
+ {emails_block}
26
+ """
27
+
28
+
29
+ class EmailMultiSummarySchema(BaseModel):
30
+ summary: str = Field(description="One paragraph summarizing and prioritizing multiple emails.")
31
+
32
+
33
+ def llm_summarize_many_emails(
34
+ messages: Sequence[EmailMessage],
35
+ *,
36
+ provider: str,
37
+ model_name: str,
38
+ ) -> Tuple[Optional[str], Dict[str, Any]]:
39
+ """
40
+ Generate a concise email reply using the LLM pipeline.
41
+ """
42
+ chain = get_model(provider, model_name, EmailMultiSummarySchema)
43
+
44
+ blocks: List[str] = []
45
+ for msg in messages:
46
+ email_context = build_email_context(msg)
47
+ blocks.append(email_context)
48
+
49
+ emails_block = "\n---\n".join(blocks)
50
+
51
+ result, llm_call_info = chain(
52
+ EMAIL_MULTI_SUMMARY_PROMPT.format(
53
+ emails_block=emails_block,
54
+ )
55
+ )
56
+
57
+ return result.summary, llm_call_info
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional, Tuple
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from openmail.llm import get_model
8
+
9
+ TRANSLATE_EMAIL_PROMPT = """
10
+ You are a translation assistant.
11
+
12
+ Instructions:
13
+ - Translate the text into the target language.
14
+ - Preserve tone and level of formality as much as reasonable.
15
+ - Do not add explanations, comments, or notes—only output the translated text.
16
+ - Keep formatting (line breaks, bullet points) where it makes sense.
17
+
18
+ Target language: {target_language}
19
+ {source_line}
20
+
21
+ Text to translate:
22
+ {text}
23
+ """
24
+
25
+
26
+ class TranslateEmailSchema(BaseModel):
27
+ translated_text: str = Field(description="The translated text in the target language.")
28
+
29
+
30
+ def llm_translate_email(
31
+ text: str,
32
+ *,
33
+ target_language: str,
34
+ source_language: Optional[str],
35
+ provider: str,
36
+ model_name: str,
37
+ ) -> Tuple[str, dict[str, Any]]:
38
+ """
39
+ Translate arbitrary text to a target language.
40
+ """
41
+ if source_language:
42
+ source_line = f"Source language: {source_language}"
43
+ else:
44
+ source_line = "Source language: auto-detect"
45
+
46
+ chain = get_model(provider, model_name, TranslateEmailSchema)
47
+ result, llm_call_info = chain(
48
+ TRANSLATE_EMAIL_PROMPT.format(
49
+ target_language=target_language,
50
+ source_line=source_line,
51
+ text=text,
52
+ )
53
+ )
54
+ return result.translated_text, llm_call_info
@@ -0,0 +1,6 @@
1
+ from openmail.auth.base import AuthContext, IMAPAuth, SMTPAuth
2
+ from openmail.auth.no_auth import NoAuth
3
+ from openmail.auth.oauth2 import OAuth2Auth
4
+ from openmail.auth.password import PasswordAuth
5
+
6
+ __all__ = ["SMTPAuth", "IMAPAuth", "AuthContext", "PasswordAuth", "OAuth2Auth", "NoAuth"]
openmail/auth/base.py ADDED
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional, Protocol, runtime_checkable
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class AuthContext:
9
+ """
10
+ Extra context an auth method may need.
11
+ (Keep this minimal; extend later.)
12
+ """
13
+
14
+ host: str
15
+ port: int
16
+ username: Optional[str] = None
17
+
18
+
19
+ @runtime_checkable
20
+ class SMTPAuth(Protocol):
21
+ """
22
+ Something that knows how to authenticate an smtplib.SMTP-like object.
23
+ """
24
+
25
+ def apply_smtp(self, server, ctx: AuthContext) -> None: ...
26
+
27
+
28
+ @runtime_checkable
29
+ class IMAPAuth(Protocol):
30
+ """
31
+ Something that knows how to authenticate an imaplib.IMAP4-like object.
32
+ """
33
+
34
+ def apply_imap(self, conn, ctx: AuthContext) -> None: ...
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from openmail.auth.base import AuthContext
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class NoAuth:
10
+ """
11
+ Represents 'no authentication required'. Useful for SMTP relays
12
+ or IMAP/SMTP servers that don't require login for this client.
13
+ """
14
+
15
+ def apply_smtp(self, server, ctx: AuthContext) -> None:
16
+ return
17
+
18
+ def apply_imap(self, conn, ctx: AuthContext) -> None:
19
+ return
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from dataclasses import dataclass
5
+ from typing import Callable
6
+
7
+ from openmail.auth.base import AuthContext
8
+ from openmail.errors import AuthError
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class OAuth2Auth:
13
+ """
14
+ XOAUTH2-based auth. You provide a function that returns a fresh access token.
15
+ - token_provider() -> access_token (string)
16
+ """
17
+
18
+ username: str
19
+ token_provider: Callable[..., str]
20
+
21
+ def _raw_xoauth2(self, access_token: str) -> str:
22
+ return f"user={self.username}\x01auth=Bearer {access_token}\x01\x01"
23
+
24
+ def apply_imap(self, conn, ctx: AuthContext) -> None:
25
+ try:
26
+ token = self.token_provider()
27
+ if not token:
28
+ raise AuthError("OAuth2 token provider returned empty token")
29
+
30
+ auth_bytes = self._raw_xoauth2(token).encode("utf-8")
31
+
32
+ def auth_cb(_):
33
+ return auth_bytes
34
+
35
+ typ, data = conn.authenticate("XOAUTH2", auth_cb)
36
+ if typ != "OK":
37
+ raise AuthError(f"IMAP XOAUTH2 auth failed (non-OK response: {typ}, {data})")
38
+ except Exception as e:
39
+ raise AuthError(f"IMAP XOAUTH2 auth failed: {e}") from e
40
+
41
+ def apply_smtp(self, server, ctx: AuthContext) -> None:
42
+ """
43
+ smtplib doesn't expose a single 'authenticate XOAUTH2' helper,
44
+ so we send AUTH XOAUTH2 with a base64-encoded initial response.
45
+ """
46
+ try:
47
+ token = self.token_provider()
48
+ if not token:
49
+ raise AuthError("OAuth2 token provider returned empty token")
50
+
51
+ raw = self._raw_xoauth2(token)
52
+ auth_b64 = base64.b64encode(raw.encode("utf-8")).decode("ascii")
53
+
54
+ code, resp = server.docmd("AUTH", "XOAUTH2 " + auth_b64)
55
+ if code != 235:
56
+ raise AuthError(f"SMTP XOAUTH2 auth failed: {code} {resp!r}")
57
+ except Exception as e:
58
+ raise AuthError(f"SMTP XOAUTH2 auth failed: {e}") from e