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.
Files changed (38) hide show
  1. read_no_evil_mcp/__init__.py +36 -0
  2. read_no_evil_mcp/__main__.py +6 -0
  3. read_no_evil_mcp/accounts/__init__.py +21 -0
  4. read_no_evil_mcp/accounts/config.py +93 -0
  5. read_no_evil_mcp/accounts/credentials/__init__.py +6 -0
  6. read_no_evil_mcp/accounts/credentials/base.py +29 -0
  7. read_no_evil_mcp/accounts/credentials/env.py +44 -0
  8. read_no_evil_mcp/accounts/permissions.py +87 -0
  9. read_no_evil_mcp/accounts/service.py +109 -0
  10. read_no_evil_mcp/config.py +98 -0
  11. read_no_evil_mcp/email/__init__.py +6 -0
  12. read_no_evil_mcp/email/connectors/__init__.py +6 -0
  13. read_no_evil_mcp/email/connectors/base.py +148 -0
  14. read_no_evil_mcp/email/connectors/imap.py +288 -0
  15. read_no_evil_mcp/email/connectors/smtp.py +110 -0
  16. read_no_evil_mcp/exceptions.py +44 -0
  17. read_no_evil_mcp/mailbox.py +329 -0
  18. read_no_evil_mcp/models.py +88 -0
  19. read_no_evil_mcp/protection/__init__.py +6 -0
  20. read_no_evil_mcp/protection/heuristic.py +82 -0
  21. read_no_evil_mcp/protection/service.py +110 -0
  22. read_no_evil_mcp/py.typed +0 -0
  23. read_no_evil_mcp/server.py +12 -0
  24. read_no_evil_mcp/tools/__init__.py +16 -0
  25. read_no_evil_mcp/tools/_app.py +6 -0
  26. read_no_evil_mcp/tools/_service.py +54 -0
  27. read_no_evil_mcp/tools/delete_email.py +24 -0
  28. read_no_evil_mcp/tools/get_email.py +64 -0
  29. read_no_evil_mcp/tools/list_accounts.py +20 -0
  30. read_no_evil_mcp/tools/list_emails.py +47 -0
  31. read_no_evil_mcp/tools/list_folders.py +22 -0
  32. read_no_evil_mcp/tools/move_email.py +29 -0
  33. read_no_evil_mcp/tools/send_email.py +43 -0
  34. read_no_evil_mcp-0.2.0.dist-info/METADATA +361 -0
  35. read_no_evil_mcp-0.2.0.dist-info/RECORD +38 -0
  36. read_no_evil_mcp-0.2.0.dist-info/WHEEL +4 -0
  37. read_no_evil_mcp-0.2.0.dist-info/entry_points.txt +2 -0
  38. 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)