read-no-evil-mcp 0.2.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.
- read_no_evil_mcp/__init__.py +36 -0
- read_no_evil_mcp/__main__.py +6 -0
- read_no_evil_mcp/accounts/__init__.py +21 -0
- read_no_evil_mcp/accounts/config.py +93 -0
- read_no_evil_mcp/accounts/credentials/__init__.py +6 -0
- read_no_evil_mcp/accounts/credentials/base.py +29 -0
- read_no_evil_mcp/accounts/credentials/env.py +44 -0
- read_no_evil_mcp/accounts/permissions.py +87 -0
- read_no_evil_mcp/accounts/service.py +109 -0
- read_no_evil_mcp/config.py +98 -0
- read_no_evil_mcp/email/__init__.py +6 -0
- read_no_evil_mcp/email/connectors/__init__.py +6 -0
- read_no_evil_mcp/email/connectors/base.py +148 -0
- read_no_evil_mcp/email/connectors/imap.py +288 -0
- read_no_evil_mcp/email/connectors/smtp.py +110 -0
- read_no_evil_mcp/exceptions.py +44 -0
- read_no_evil_mcp/mailbox.py +329 -0
- read_no_evil_mcp/models.py +88 -0
- read_no_evil_mcp/protection/__init__.py +6 -0
- read_no_evil_mcp/protection/heuristic.py +82 -0
- read_no_evil_mcp/protection/service.py +110 -0
- read_no_evil_mcp/py.typed +0 -0
- read_no_evil_mcp/server.py +12 -0
- read_no_evil_mcp/tools/__init__.py +16 -0
- read_no_evil_mcp/tools/_app.py +6 -0
- read_no_evil_mcp/tools/_service.py +54 -0
- read_no_evil_mcp/tools/delete_email.py +24 -0
- read_no_evil_mcp/tools/get_email.py +64 -0
- read_no_evil_mcp/tools/list_accounts.py +20 -0
- read_no_evil_mcp/tools/list_emails.py +47 -0
- read_no_evil_mcp/tools/list_folders.py +22 -0
- read_no_evil_mcp/tools/move_email.py +29 -0
- read_no_evil_mcp/tools/send_email.py +43 -0
- read_no_evil_mcp-0.2.0.dist-info/METADATA +361 -0
- read_no_evil_mcp-0.2.0.dist-info/RECORD +38 -0
- read_no_evil_mcp-0.2.0.dist-info/WHEEL +4 -0
- read_no_evil_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- read_no_evil_mcp-0.2.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Abstract base class for email connectors."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from datetime import date, timedelta
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
|
|
7
|
+
from read_no_evil_mcp.models import Email, EmailSummary, Folder
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseConnector(ABC):
|
|
11
|
+
"""Abstract base class defining the interface for email connectors."""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def connect(self) -> None:
|
|
15
|
+
"""Establish connection to the email server."""
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def disconnect(self) -> None:
|
|
20
|
+
"""Close connection to the email server."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def list_folders(self) -> list[Folder]:
|
|
25
|
+
"""List all available folders/mailboxes.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
List of Folder objects representing available mailboxes.
|
|
29
|
+
"""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def fetch_emails(
|
|
34
|
+
self,
|
|
35
|
+
folder: str = "INBOX",
|
|
36
|
+
*,
|
|
37
|
+
lookback: timedelta,
|
|
38
|
+
from_date: date | None = None,
|
|
39
|
+
limit: int | None = None,
|
|
40
|
+
) -> list[EmailSummary]:
|
|
41
|
+
"""Fetch email summaries from a folder within a time range.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
folder: Folder/mailbox to fetch from (default: INBOX)
|
|
45
|
+
lookback: How far back to look from from_date
|
|
46
|
+
from_date: Starting point for lookback (default: today)
|
|
47
|
+
limit: Maximum number of emails to return
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
List of EmailSummary objects, newest first.
|
|
51
|
+
"""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
def get_email(self, folder: str, uid: int) -> Email | None:
|
|
56
|
+
"""Fetch full email content by UID.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
folder: Folder/mailbox containing the email
|
|
60
|
+
uid: Unique identifier of the email
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Full Email object or None if not found.
|
|
64
|
+
"""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def move_email(self, folder: str, uid: int, target_folder: str) -> bool:
|
|
69
|
+
"""Move an email to a target folder.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
folder: Folder/mailbox containing the email
|
|
73
|
+
uid: Unique identifier of the email
|
|
74
|
+
target_folder: Destination folder to move the email to
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
True if successful, False if email not found.
|
|
78
|
+
"""
|
|
79
|
+
...
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
def delete_email(self, folder: str, uid: int) -> bool:
|
|
83
|
+
"""Delete an email by UID.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
folder: Folder/mailbox containing the email
|
|
87
|
+
uid: Unique identifier of the email
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
True if email was deleted successfully, False otherwise.
|
|
91
|
+
"""
|
|
92
|
+
...
|
|
93
|
+
|
|
94
|
+
def can_send(self) -> bool:
|
|
95
|
+
"""Check if this connector supports sending emails.
|
|
96
|
+
|
|
97
|
+
Override this method to return True in connectors that support sending.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
True if send() is supported, False otherwise.
|
|
101
|
+
"""
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
def send(
|
|
105
|
+
self,
|
|
106
|
+
from_address: str,
|
|
107
|
+
to: list[str],
|
|
108
|
+
subject: str,
|
|
109
|
+
body: str,
|
|
110
|
+
from_name: str | None = None,
|
|
111
|
+
cc: list[str] | None = None,
|
|
112
|
+
reply_to: str | None = None,
|
|
113
|
+
) -> bool:
|
|
114
|
+
"""Send an email (optional capability).
|
|
115
|
+
|
|
116
|
+
This is an optional method. Connectors that support sending emails
|
|
117
|
+
should override this method and can_send() to return True.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
from_address: Sender email address (e.g., "user@example.com").
|
|
121
|
+
to: List of recipient email addresses.
|
|
122
|
+
subject: Email subject line.
|
|
123
|
+
body: Email body text (plain text).
|
|
124
|
+
from_name: Optional display name for sender (e.g., "Atlas").
|
|
125
|
+
cc: Optional list of CC recipients.
|
|
126
|
+
reply_to: Optional Reply-To email address.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
True if email was sent successfully.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
NotImplementedError: If the connector doesn't support sending.
|
|
133
|
+
"""
|
|
134
|
+
raise NotImplementedError(f"{self.__class__.__name__} does not support sending emails")
|
|
135
|
+
|
|
136
|
+
def __enter__(self) -> "BaseConnector":
|
|
137
|
+
"""Context manager entry - connect to server."""
|
|
138
|
+
self.connect()
|
|
139
|
+
return self
|
|
140
|
+
|
|
141
|
+
def __exit__(
|
|
142
|
+
self,
|
|
143
|
+
exc_type: type[BaseException] | None,
|
|
144
|
+
exc_val: BaseException | None,
|
|
145
|
+
exc_tb: TracebackType | None,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Context manager exit - disconnect from server."""
|
|
148
|
+
self.disconnect()
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""IMAP connector for reading emails using imap-tools."""
|
|
2
|
+
|
|
3
|
+
from datetime import date, timedelta
|
|
4
|
+
|
|
5
|
+
from imap_tools import AND, MailBox, MailBoxUnencrypted
|
|
6
|
+
from imap_tools import EmailAddress as IMAPEmailAddress
|
|
7
|
+
|
|
8
|
+
from read_no_evil_mcp.email.connectors.base import BaseConnector
|
|
9
|
+
from read_no_evil_mcp.email.connectors.smtp import SMTPConnector
|
|
10
|
+
from read_no_evil_mcp.models import (
|
|
11
|
+
Attachment,
|
|
12
|
+
Email,
|
|
13
|
+
EmailAddress,
|
|
14
|
+
EmailSummary,
|
|
15
|
+
Folder,
|
|
16
|
+
IMAPConfig,
|
|
17
|
+
SMTPConfig,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Default sender for emails without from address
|
|
21
|
+
_DEFAULT_SENDER = EmailAddress(address="unknown@unknown")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _convert_address(addr: IMAPEmailAddress | None) -> EmailAddress:
|
|
25
|
+
"""Convert imap-tools EmailAddress to our EmailAddress model."""
|
|
26
|
+
if addr is None or not addr.email:
|
|
27
|
+
return _DEFAULT_SENDER
|
|
28
|
+
return EmailAddress(name=addr.name or None, address=addr.email)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _convert_addresses(addrs: tuple[IMAPEmailAddress, ...]) -> list[EmailAddress]:
|
|
32
|
+
"""Convert tuple of imap-tools EmailAddress to list of our EmailAddress model."""
|
|
33
|
+
return [
|
|
34
|
+
EmailAddress(name=addr.name or None, address=addr.email) for addr in addrs if addr.email
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class IMAPConnector(BaseConnector):
|
|
39
|
+
"""Connector for reading emails via IMAP using imap-tools.
|
|
40
|
+
|
|
41
|
+
Optionally supports sending emails via SMTP when smtp_config is provided.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
config: IMAPConfig,
|
|
47
|
+
smtp_config: SMTPConfig | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Initialize IMAP connector.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
config: IMAP server configuration.
|
|
53
|
+
smtp_config: Optional SMTP configuration for sending emails.
|
|
54
|
+
"""
|
|
55
|
+
self.config = config
|
|
56
|
+
self._mailbox: MailBox | MailBoxUnencrypted | None = None
|
|
57
|
+
self._smtp_config = smtp_config
|
|
58
|
+
self._smtp_connector: SMTPConnector | None = None
|
|
59
|
+
|
|
60
|
+
def connect(self) -> None:
|
|
61
|
+
"""Establish connection to IMAP server (and SMTP if configured)."""
|
|
62
|
+
if self.config.ssl:
|
|
63
|
+
mailbox: MailBox | MailBoxUnencrypted = MailBox(self.config.host, self.config.port)
|
|
64
|
+
else:
|
|
65
|
+
mailbox = MailBoxUnencrypted(self.config.host, self.config.port)
|
|
66
|
+
|
|
67
|
+
mailbox.login(
|
|
68
|
+
self.config.username,
|
|
69
|
+
self.config.password.get_secret_value(),
|
|
70
|
+
)
|
|
71
|
+
self._mailbox = mailbox
|
|
72
|
+
|
|
73
|
+
# Connect to SMTP if configured
|
|
74
|
+
if self._smtp_config:
|
|
75
|
+
self._smtp_connector = SMTPConnector(self._smtp_config)
|
|
76
|
+
self._smtp_connector.connect()
|
|
77
|
+
|
|
78
|
+
def disconnect(self) -> None:
|
|
79
|
+
"""Close connection to IMAP server (and SMTP if connected)."""
|
|
80
|
+
if self._mailbox:
|
|
81
|
+
self._mailbox.logout()
|
|
82
|
+
self._mailbox = None
|
|
83
|
+
|
|
84
|
+
if self._smtp_connector:
|
|
85
|
+
self._smtp_connector.disconnect()
|
|
86
|
+
self._smtp_connector = None
|
|
87
|
+
|
|
88
|
+
def list_folders(self) -> list[Folder]:
|
|
89
|
+
"""List all folders/mailboxes."""
|
|
90
|
+
if not self._mailbox:
|
|
91
|
+
raise RuntimeError("Not connected. Call connect() first.")
|
|
92
|
+
|
|
93
|
+
folders = []
|
|
94
|
+
for folder_info in self._mailbox.folder.list():
|
|
95
|
+
folders.append(
|
|
96
|
+
Folder(
|
|
97
|
+
name=folder_info.name,
|
|
98
|
+
delimiter=folder_info.delim,
|
|
99
|
+
flags=list(folder_info.flags),
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
return folders
|
|
103
|
+
|
|
104
|
+
def fetch_emails(
|
|
105
|
+
self,
|
|
106
|
+
folder: str = "INBOX",
|
|
107
|
+
*,
|
|
108
|
+
lookback: timedelta,
|
|
109
|
+
from_date: date | None = None,
|
|
110
|
+
limit: int | None = None,
|
|
111
|
+
) -> list[EmailSummary]:
|
|
112
|
+
"""Fetch email summaries from a folder within a time range.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
folder: IMAP folder to fetch from (default: INBOX)
|
|
116
|
+
lookback: Required. How far back to look (e.g., timedelta(days=7))
|
|
117
|
+
from_date: Starting point for lookback (default: today)
|
|
118
|
+
limit: Optional max number of emails to return
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
List of EmailSummary, newest first
|
|
122
|
+
"""
|
|
123
|
+
if not self._mailbox:
|
|
124
|
+
raise RuntimeError("Not connected. Call connect() first.")
|
|
125
|
+
|
|
126
|
+
self._mailbox.folder.set(folder)
|
|
127
|
+
|
|
128
|
+
# Calculate date range
|
|
129
|
+
end_date = from_date or date.today()
|
|
130
|
+
start_date = end_date - lookback
|
|
131
|
+
|
|
132
|
+
# Build IMAP criteria - filter happens server-side
|
|
133
|
+
criteria = AND(date_gte=start_date, date_lt=end_date + timedelta(days=1))
|
|
134
|
+
|
|
135
|
+
summaries = []
|
|
136
|
+
for msg in self._mailbox.fetch(criteria, reverse=True, bulk=True):
|
|
137
|
+
sender = _convert_address(msg.from_values)
|
|
138
|
+
|
|
139
|
+
summaries.append(
|
|
140
|
+
EmailSummary(
|
|
141
|
+
uid=int(msg.uid) if msg.uid else 0,
|
|
142
|
+
folder=folder,
|
|
143
|
+
subject=msg.subject or "(no subject)",
|
|
144
|
+
sender=sender,
|
|
145
|
+
date=msg.date,
|
|
146
|
+
has_attachments=len(msg.attachments) > 0,
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if limit and len(summaries) >= limit:
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
return summaries
|
|
154
|
+
|
|
155
|
+
def get_email(self, folder: str, uid: int) -> Email | None:
|
|
156
|
+
"""Fetch full email content by UID."""
|
|
157
|
+
if not self._mailbox:
|
|
158
|
+
raise RuntimeError("Not connected. Call connect() first.")
|
|
159
|
+
|
|
160
|
+
self._mailbox.folder.set(folder)
|
|
161
|
+
|
|
162
|
+
for msg in self._mailbox.fetch(AND(uid=str(uid))):
|
|
163
|
+
sender = _convert_address(msg.from_values)
|
|
164
|
+
|
|
165
|
+
attachments = [
|
|
166
|
+
Attachment(
|
|
167
|
+
filename=att.filename or "unnamed",
|
|
168
|
+
content_type=att.content_type or "application/octet-stream",
|
|
169
|
+
size=att.size,
|
|
170
|
+
)
|
|
171
|
+
for att in msg.attachments
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
return Email(
|
|
175
|
+
uid=int(msg.uid) if msg.uid else 0,
|
|
176
|
+
folder=folder,
|
|
177
|
+
subject=msg.subject or "(no subject)",
|
|
178
|
+
sender=sender,
|
|
179
|
+
date=msg.date,
|
|
180
|
+
has_attachments=len(attachments) > 0,
|
|
181
|
+
to=_convert_addresses(msg.to_values),
|
|
182
|
+
cc=_convert_addresses(msg.cc_values),
|
|
183
|
+
body_plain=msg.text or None,
|
|
184
|
+
body_html=msg.html or None,
|
|
185
|
+
attachments=attachments,
|
|
186
|
+
message_id=msg.headers.get("message-id", [None])[0],
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
def move_email(self, folder: str, uid: int, target_folder: str) -> bool:
|
|
192
|
+
"""Move an email to a target folder.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
folder: Folder/mailbox containing the email
|
|
196
|
+
uid: Unique identifier of the email
|
|
197
|
+
target_folder: Destination folder to move the email to
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
True if successful, False if email not found.
|
|
201
|
+
"""
|
|
202
|
+
if not self._mailbox:
|
|
203
|
+
raise RuntimeError("Not connected. Call connect() first.")
|
|
204
|
+
|
|
205
|
+
self._mailbox.folder.set(folder)
|
|
206
|
+
|
|
207
|
+
# Check if email exists
|
|
208
|
+
emails = list(self._mailbox.fetch(AND(uid=str(uid))))
|
|
209
|
+
if not emails:
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
# Move email to target folder
|
|
213
|
+
self._mailbox.move(str(uid), target_folder)
|
|
214
|
+
return True
|
|
215
|
+
|
|
216
|
+
def delete_email(self, folder: str, uid: int) -> bool:
|
|
217
|
+
"""Delete an email by UID.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
folder: IMAP folder containing the email
|
|
221
|
+
uid: Unique identifier of the email
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
True if email was deleted successfully, False otherwise.
|
|
225
|
+
"""
|
|
226
|
+
if not self._mailbox:
|
|
227
|
+
raise RuntimeError("Not connected. Call connect() first.")
|
|
228
|
+
|
|
229
|
+
self._mailbox.folder.set(folder)
|
|
230
|
+
|
|
231
|
+
# Use imap-tools delete method to mark email as deleted
|
|
232
|
+
self._mailbox.delete(str(uid))
|
|
233
|
+
|
|
234
|
+
return True
|
|
235
|
+
|
|
236
|
+
def can_send(self) -> bool:
|
|
237
|
+
"""Check if this connector supports sending emails.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
True if SMTP is configured, False otherwise.
|
|
241
|
+
"""
|
|
242
|
+
return self._smtp_config is not None
|
|
243
|
+
|
|
244
|
+
def send(
|
|
245
|
+
self,
|
|
246
|
+
from_address: str,
|
|
247
|
+
to: list[str],
|
|
248
|
+
subject: str,
|
|
249
|
+
body: str,
|
|
250
|
+
from_name: str | None = None,
|
|
251
|
+
cc: list[str] | None = None,
|
|
252
|
+
reply_to: str | None = None,
|
|
253
|
+
) -> bool:
|
|
254
|
+
"""Send an email via SMTP.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
from_address: Sender email address (e.g., "user@example.com").
|
|
258
|
+
to: List of recipient email addresses.
|
|
259
|
+
subject: Email subject line.
|
|
260
|
+
body: Email body text (plain text).
|
|
261
|
+
from_name: Optional display name for sender (e.g., "Atlas").
|
|
262
|
+
cc: Optional list of CC recipients.
|
|
263
|
+
reply_to: Optional Reply-To email address.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
True if email was sent successfully.
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
NotImplementedError: If SMTP is not configured.
|
|
270
|
+
RuntimeError: If not connected.
|
|
271
|
+
"""
|
|
272
|
+
if not self._smtp_config:
|
|
273
|
+
raise NotImplementedError(
|
|
274
|
+
"IMAPConnector does not support sending emails without SMTP configuration"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if not self._smtp_connector:
|
|
278
|
+
raise RuntimeError("Not connected. Call connect() first.")
|
|
279
|
+
|
|
280
|
+
return self._smtp_connector.send_email(
|
|
281
|
+
from_address=from_address,
|
|
282
|
+
to=to,
|
|
283
|
+
subject=subject,
|
|
284
|
+
body=body,
|
|
285
|
+
from_name=from_name,
|
|
286
|
+
cc=cc,
|
|
287
|
+
reply_to=reply_to,
|
|
288
|
+
)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""SMTP connector for sending emails using smtplib."""
|
|
2
|
+
|
|
3
|
+
import smtplib
|
|
4
|
+
from email.mime.multipart import MIMEMultipart
|
|
5
|
+
from email.mime.text import MIMEText
|
|
6
|
+
|
|
7
|
+
from read_no_evil_mcp.models import SMTPConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SMTPConnector:
|
|
11
|
+
"""Connector for sending emails via SMTP using smtplib."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, config: SMTPConfig) -> None:
|
|
14
|
+
"""Initialize SMTP connector.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
config: SMTP server configuration.
|
|
18
|
+
"""
|
|
19
|
+
self.config = config
|
|
20
|
+
self._connection: smtplib.SMTP | smtplib.SMTP_SSL | None = None
|
|
21
|
+
|
|
22
|
+
def connect(self) -> None:
|
|
23
|
+
"""Establish connection to SMTP server."""
|
|
24
|
+
if self.config.ssl:
|
|
25
|
+
self._connection = smtplib.SMTP_SSL(self.config.host, self.config.port)
|
|
26
|
+
else:
|
|
27
|
+
self._connection = smtplib.SMTP(self.config.host, self.config.port)
|
|
28
|
+
self._connection.starttls()
|
|
29
|
+
|
|
30
|
+
self._connection.login(
|
|
31
|
+
self.config.username,
|
|
32
|
+
self.config.password.get_secret_value(),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def disconnect(self) -> None:
|
|
36
|
+
"""Close connection to SMTP server."""
|
|
37
|
+
if self._connection:
|
|
38
|
+
self._connection.quit()
|
|
39
|
+
self._connection = None
|
|
40
|
+
|
|
41
|
+
def __enter__(self) -> "SMTPConnector":
|
|
42
|
+
"""Enter context manager, connecting to the server."""
|
|
43
|
+
self.connect()
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def __exit__(
|
|
47
|
+
self,
|
|
48
|
+
exc_type: type[BaseException] | None,
|
|
49
|
+
exc_val: BaseException | None,
|
|
50
|
+
exc_tb: object,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Exit context manager, disconnecting from the server."""
|
|
53
|
+
self.disconnect()
|
|
54
|
+
|
|
55
|
+
def send_email(
|
|
56
|
+
self,
|
|
57
|
+
from_address: str,
|
|
58
|
+
to: list[str],
|
|
59
|
+
subject: str,
|
|
60
|
+
body: str,
|
|
61
|
+
from_name: str | None = None,
|
|
62
|
+
cc: list[str] | None = None,
|
|
63
|
+
reply_to: str | None = None,
|
|
64
|
+
) -> bool:
|
|
65
|
+
"""Send an email.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
from_address: Sender email address (e.g., "user@example.com").
|
|
69
|
+
to: List of recipient email addresses.
|
|
70
|
+
subject: Email subject line.
|
|
71
|
+
body: Email body text (plain text).
|
|
72
|
+
from_name: Optional display name for sender (e.g., "Atlas").
|
|
73
|
+
cc: Optional list of CC recipients.
|
|
74
|
+
reply_to: Optional Reply-To email address.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
True if email was sent successfully.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
RuntimeError: If not connected to SMTP server.
|
|
81
|
+
smtplib.SMTPException: If sending fails.
|
|
82
|
+
"""
|
|
83
|
+
if not self._connection:
|
|
84
|
+
raise RuntimeError("Not connected. Call connect() first.")
|
|
85
|
+
|
|
86
|
+
msg = MIMEMultipart()
|
|
87
|
+
# Build From header with optional display name
|
|
88
|
+
if from_name:
|
|
89
|
+
msg["From"] = f"{from_name} <{from_address}>"
|
|
90
|
+
else:
|
|
91
|
+
msg["From"] = from_address
|
|
92
|
+
msg["To"] = ", ".join(to)
|
|
93
|
+
msg["Subject"] = subject
|
|
94
|
+
|
|
95
|
+
if cc:
|
|
96
|
+
msg["Cc"] = ", ".join(cc)
|
|
97
|
+
|
|
98
|
+
if reply_to:
|
|
99
|
+
msg["Reply-To"] = reply_to
|
|
100
|
+
|
|
101
|
+
msg.attach(MIMEText(body, "plain"))
|
|
102
|
+
|
|
103
|
+
# Build recipient list (to + cc)
|
|
104
|
+
recipients = list(to)
|
|
105
|
+
if cc:
|
|
106
|
+
recipients.extend(cc)
|
|
107
|
+
|
|
108
|
+
# Use from_address directly for SMTP envelope (no parsing needed)
|
|
109
|
+
self._connection.sendmail(from_address, recipients, msg.as_string())
|
|
110
|
+
return True
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Custom exceptions for read-no-evil-mcp."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ReadNoEvilError(Exception):
|
|
5
|
+
"""Base exception for read-no-evil-mcp."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigError(ReadNoEvilError):
|
|
9
|
+
"""Raised when there is a configuration error."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AccountNotFoundError(ReadNoEvilError):
|
|
13
|
+
"""Raised when a requested account is not found."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, account_id: str) -> None:
|
|
16
|
+
self.account_id = account_id
|
|
17
|
+
super().__init__(f"Account not found: {account_id}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CredentialNotFoundError(ConfigError):
|
|
21
|
+
"""Raised when credentials for an account are not found."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, account_id: str, env_key: str) -> None:
|
|
24
|
+
self.account_id = account_id
|
|
25
|
+
self.env_key = env_key
|
|
26
|
+
super().__init__(
|
|
27
|
+
f"Missing credential for account '{account_id}': "
|
|
28
|
+
f"environment variable {env_key} is not set"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UnsupportedConnectorError(ConfigError):
|
|
33
|
+
"""Raised when an unsupported connector type is requested."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, connector_type: str) -> None:
|
|
36
|
+
self.connector_type = connector_type
|
|
37
|
+
super().__init__(f"Unsupported connector type: {connector_type}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PermissionDeniedError(ReadNoEvilError):
|
|
41
|
+
"""Raised when an operation is not permitted for an account."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str) -> None:
|
|
44
|
+
super().__init__(message)
|