imap_mailbox
Please note that imap_mailbox is still under active development and will be subject to significant changes.
import imap_mailbox
# connect to the IMAP server
with imap_mailbox.IMAPMailbox('imap.example.com', 'username', 'password') as mailbox:
# search messages from vip@example.com
uids = mailbox.search('FROM', 'vip@example.com')
# move the messages to the 'VIP' folder
mailbox.move(uids, 'VIP')
This module provides a subclass of mailbox.Mailbox that allows you to interact with an IMAP server. It is designed to be a drop-in replacement for the standard library mailbox module.
Installation
Install the latest stable version from PyPI:
pip install imap-mailbox
Examples
Iterate over messages in a folder
import imap_mailbox
# connect to the IMAP server
with imap_mailbox.IMAPMailbox('imap.example.com', 'username', 'password') as mailbox:
# select the INBOX folder
mailbox.select('INBOX')
# iterate over messages in the folder
for message in mailbox:
print(f"From: {message['From']}")
print(f"Subject: {message['Subject']}")
Connect to a Proton Mail account
import imap_mailbox
# connect to the local IMAP bridge
with imap_mailbox.IMAPMailbox(
'127.0.0.1', 'username', 'password'
port=1143, security='STARTTLS'
) as mailbox:
# search messages from your friend
uids = mailbox.search('FROM', 'tr3nton@proton.me')
# erase the evidence
mailbox.delete(uids)
Delete messages from a noisy sender
import imap_mailbox
with imap_mailbox.IMAPMailbox('imap.example.com', 'username', 'password') as mailbox:
# search messages from
uids = mailbox.search('FROM', 'spammer@example.com')
# delete the messages
mailbox.delete(uids)
Delete GitHub messages older than two years
import imap_mailbox
with imap_mailbox.IMAPMailbox('imap.example.com', 'username', 'password') as mailbox:
# search messages older than two years from github.com
uids = mailbox.search('NOT PAST2YEARS FROM github.com')
# delete the messages
mailbox.delete(uids)
Development
Set up the development environment with dependencies and git hooks:
./scripts/init-dev.sh
Contribution
Help improve imap_mailbox by reporting any issues or suggestions on our issue tracker at github.com/medecau/imap_mailbox/issues.
Get involved with the development, check out the source code at github.com/medecau/imap_mailbox.
1""" 2.. include:: ../README.md 3""" 4 5import datetime 6import email 7import email.header 8import imaplib 9import logging 10import mailbox 11import os 12import re 13import time 14 15__all__ = ["IMAPMailbox", "IMAPMessage", "IMAPError"] 16 17MESSAGE_HEAD_RE = re.compile(r"(\d+) \(([^\s]+) {(\d+)}$") 18FOLDER_DATA_RE = re.compile(r"\(([^)]+)\) \"([^\"]+)\" \"?([^\"]+)\"?$") 19 20 21log = logging.getLogger(__name__) 22log.setLevel(getattr(logging, os.getenv("LOG_LEVEL", "INFO"))) 23 24 25class IMAPError(Exception): 26 """Exception raised for IMAP operation errors.""" 27 28 pass 29 30 31def handle_response(response): 32 """Handle the response from the IMAP server""" 33 status, data = response 34 if status != "OK": 35 raise IMAPError(data[0]) 36 37 return data 38 39 40def change_time(time, weeks=0, days=0, hours=0, minutes=0, seconds=0): 41 """Change the time by a given amount of days, hours, minutes and seconds""" 42 return time + datetime.timedelta( 43 weeks=weeks, days=days, hours=hours, minutes=minutes, seconds=seconds 44 ) 45 46 47def imap_date(time): 48 """Convert a datetime object to an IMAP date string""" 49 return time.strftime("%d-%b-%Y") 50 51 52def imap_date_range(start, end): 53 """Create an IMAP date range string for use in a search query""" 54 return f"(SINCE {imap_date(start)} BEFORE {imap_date(end)})" 55 56 57class IMAPMessage(mailbox.Message): 58 """A Mailbox Message class that uses an IMAPClient object to fetch the message 59 60 Supports lazy loading: messages can be created with headers only, and the full 61 body is fetched transparently when accessed. 62 """ 63 64 def __init__(self, message=None, uid=None, mailbox_ref=None): 65 """Create a new IMAPMessage 66 67 Args: 68 message: Email message bytes or Message object 69 uid: Message UID for lazy loading (optional) 70 mailbox_ref: Reference to IMAPMailbox for lazy loading (optional) 71 """ 72 super().__init__(message) 73 self._uid = uid 74 self._mailbox_ref = mailbox_ref 75 self._body_loaded = ( 76 mailbox_ref is None 77 ) # If no mailbox ref, body is already loaded 78 79 @classmethod 80 def from_uid(cls, uid, mailbox, headers_only=False): 81 """Create a new message from a UID 82 83 Args: 84 uid: Message UID 85 mailbox: IMAPMailbox instance 86 headers_only: If True, fetch only headers for lazy loading 87 """ 88 if headers_only: 89 # Fetch headers only, store reference for lazy body loading 90 _, body = next(mailbox.fetch(uid, "RFC822.HEADER")) 91 return cls(body, uid=uid, mailbox_ref=mailbox) 92 else: 93 # Fetch full message immediately 94 _, body = next(mailbox.fetch(uid, "RFC822")) 95 return cls(body, uid=uid) 96 97 @property 98 def uid(self): 99 """Get the message UID""" 100 return self._uid 101 102 def _ensure_body_loaded(self): 103 """Ensure the full message body is loaded 104 105 If the message was created with headers_only=True, this will fetch 106 the full message from the IMAP server. 107 108 Raises: 109 RuntimeError: If the IMAP connection is closed 110 """ 111 if self._body_loaded: 112 return 113 114 if self._mailbox_ref is None: 115 raise RuntimeError("Cannot load body: IMAP connection is closed") 116 117 # Fetch the full message 118 _, body = next(self._mailbox_ref.fetch(self._uid, "RFC822")) 119 120 # Parse the full message 121 full_msg = email.message_from_bytes(body) 122 123 # Update our payload from the parsed message 124 self._payload = full_msg._payload 125 126 # Clear the mailbox reference to allow garbage collection 127 self._mailbox_ref = None 128 self._body_loaded = True 129 130 def __getitem__(self, name: str): 131 """Get a message header 132 133 This method overrides the default implementation of accessing a message headers. 134 The header is decoded using the email.header.decode_header method. This allows 135 for the retrieval of headers that contain non-ASCII characters. 136 """ 137 original_header = super().__getitem__(name) 138 139 if original_header is None: 140 return None 141 142 decoded_pairs = email.header.decode_header(original_header) 143 decoded_chunks = [] 144 for data, charset in decoded_pairs: 145 if isinstance(data, str): 146 decoded_chunks.append(data) 147 elif charset is None: 148 decoded_chunks.append(data.decode()) 149 elif charset == "unknown-8bit": 150 decoded_chunks.append(data.decode("utf-8", "replace")) 151 else: 152 decoded_chunks.append(data.decode(charset, "replace")) 153 154 return " ".join(decoded_chunks) 155 156 # Override body-accessing methods to ensure body is loaded 157 158 def get_payload(self, *args, **kwargs): 159 """Get the message payload, ensuring body is loaded""" 160 self._ensure_body_loaded() 161 return super().get_payload(*args, **kwargs) 162 163 def is_multipart(self): 164 """Check if message is multipart, ensuring body is loaded""" 165 self._ensure_body_loaded() 166 return super().is_multipart() 167 168 def walk(self): 169 """Walk the message tree, ensuring body is loaded""" 170 self._ensure_body_loaded() 171 return super().walk() 172 173 def as_string(self, *args, **kwargs): 174 """Return message as string, ensuring body is loaded""" 175 self._ensure_body_loaded() 176 return super().as_string(*args, **kwargs) 177 178 def as_bytes(self, *args, **kwargs): 179 """Return message as bytes, ensuring body is loaded""" 180 self._ensure_body_loaded() 181 return super().as_bytes(*args, **kwargs) 182 183 def set_payload(self, *args, **kwargs): 184 """Set the message payload, ensuring body is loaded""" 185 self._ensure_body_loaded() 186 return super().set_payload(*args, **kwargs) 187 188 def attach(self, *args, **kwargs): 189 """Attach a payload, ensuring body is loaded""" 190 self._ensure_body_loaded() 191 return super().attach(*args, **kwargs) 192 193 194class IMAPMailbox(mailbox.Mailbox): 195 """A Mailbox class that uses an IMAPClient object as the backend""" 196 197 def __init__(self, host, user, password, folder="INBOX", port=993, security="SSL"): 198 """Create a new IMAPMailbox object""" 199 self.host = host 200 self.user = user 201 self.password = password 202 self.__folder = folder 203 self.__security = security 204 self.__port = port 205 206 def connect(self): 207 """Connect to the IMAP server""" 208 if self.__security == "SSL": 209 log.info("Connecting to IMAP server using SSL") 210 self.__m = imaplib.IMAP4_SSL(self.host, self.__port) 211 elif self.__security == "STARTTLS": 212 log.info("Connecting to IMAP server using STARTTLS") 213 self.__m = imaplib.IMAP4(self.host, self.__port) 214 self.__m.starttls() 215 elif self.__security == "PLAIN": 216 log.info("Connecting to IMAP server without encryption (insecure)") 217 self.__m = imaplib.IMAP4(self.host, self.__port) 218 else: 219 raise ValueError("Invalid security type") 220 self.__m.login(self.user, self.password) 221 self.select(self.__folder) 222 223 def disconnect(self): 224 """Disconnect from the IMAP server""" 225 226 log.info("Disconnecting from IMAP server") 227 self.__m.close() 228 self.__m.logout() 229 230 def __enter__(self): 231 self.connect() 232 return self 233 234 def __exit__(self, *args): 235 self.disconnect() 236 237 def __iter__(self): 238 """Iterate over all messages in the mailbox""" 239 data = handle_response(self.__m.search(None, "ALL")) 240 for uid in data[0].decode().split(): 241 yield IMAPMessage.from_uid(uid, self, headers_only=True) 242 243 def values(self): 244 yield from iter(self) 245 246 def keys(self) -> list[str]: 247 """Get a list of all message UIDs in the mailbox""" 248 data = handle_response(self.__m.search(None, "ALL")) 249 return data[0].decode().split() 250 251 def iterkeys(self): 252 """Return an iterator over keys.""" 253 data = handle_response(self.__m.search(None, "ALL")) 254 yield from data[0].decode().split() 255 256 def __contains__(self, key): 257 """Return True if the keyed message exists, False otherwise.""" 258 return str(key) in self.keys() 259 260 def get_bytes(self, key): 261 """Return a byte string representation or raise KeyError.""" 262 if key not in self: 263 raise KeyError(key) 264 _, body = next(self.fetch(key, "RFC822")) 265 return body 266 267 def get_file(self, key): 268 """Return a file-like representation or raise KeyError.""" 269 import io 270 271 return io.BytesIO(self.get_bytes(key)) 272 273 def get_message(self, key): 274 """Return a Message representation or raise KeyError.""" 275 if key not in self: 276 raise KeyError(key) 277 return IMAPMessage.from_uid(key, self, headers_only=False) 278 279 def __getitem__(self, key): 280 """Return the keyed message; raise KeyError if it doesn't exist.""" 281 return self.get_message(key) 282 283 def __setitem__(self, key, message): 284 """Replace the keyed message; raise KeyError if it doesn't exist.""" 285 if key not in self: 286 raise KeyError(key) 287 self.remove(key) 288 self.add(message) 289 290 def items(self): 291 """Iterate over all messages as (uid, message) tuples""" 292 data = handle_response(self.__m.search(None, "ALL")) 293 for uid in data[0].decode().split(): 294 msg = IMAPMessage.from_uid(uid, self, headers_only=True) 295 yield uid, msg 296 297 @property 298 def capability(self): 299 """Get the server capabilities""" 300 return handle_response(self.__m.capability())[0].decode() 301 302 def add(self, message): 303 """Add a message to the mailbox""" 304 305 self.__m.append( 306 self.current_folder, 307 "", 308 imaplib.Time2Internaldate(time.time()), 309 message.as_bytes(), 310 ) 311 312 def copy(self, messageset: bytes, folder: str) -> None: 313 """Copy a message to a different folder""" 314 315 self.__m.copy(messageset, folder) 316 317 def move(self, messageset: bytes, folder: str) -> None: 318 """Move a message to a different folder""" 319 320 self.__m._simple_command("MOVE", messageset, folder) 321 322 def remove(self, key): 323 """Remove the keyed message; raise KeyError if it doesn't exist.""" 324 if key not in self: 325 raise KeyError(key) 326 self.__m.store(key, "+FLAGS", "\\Deleted") 327 self.__m.expunge() 328 329 def discard(self, key): 330 """If the keyed message exists, remove it.""" 331 try: 332 self.remove(key) 333 except KeyError: 334 pass 335 336 def __delitem__(self, key): 337 """Remove the keyed message; raise KeyError if it doesn't exist.""" 338 self.remove(key) 339 340 def clear(self): 341 """Remove all messages from the mailbox.""" 342 for key in self.keys(): 343 self.__m.store(key, "+FLAGS", "\\Deleted") 344 self.__m.expunge() 345 346 def __len__(self) -> int: 347 return len(self.keys()) 348 349 def fetch(self, messageset: bytes, what): 350 """Fetch messages from the mailbox""" 351 352 response = handle_response(self.__m.fetch(messageset, what)) 353 354 # Filter response to only include message data (tuples), not FLAGS (bytes) 355 messages = [item for item in response if isinstance(item, tuple)] 356 357 for head, body in messages: 358 uid, what, size = MESSAGE_HEAD_RE.match(head.decode()).groups() 359 if size != str(len(body)): 360 raise IMAPError("Size mismatch") 361 362 yield uid, body 363 364 def __expand_search_macros(self, query) -> str: 365 """Expand search macros in the query.""" 366 367 today = datetime.date.today() 368 yesterday = today - datetime.timedelta(days=1) 369 370 week_start = today - datetime.timedelta(days=today.weekday()) 371 last_week_start = week_start - datetime.timedelta(days=7) 372 373 month_start = datetime.date(today.year, today.month, 1) 374 year_start = datetime.date(today.year, 1, 1) 375 376 if today.month == 1: # January 377 # last month is December of the previous year 378 last_month_start = datetime.date(today.year - 1, 12, 1) 379 else: 380 last_month_start = datetime.date(today.year, today.month - 1, 1) 381 382 last_year_start = datetime.date(today.year - 1, 1, 1) 383 384 q = query 385 q = q.replace("FIND", "TEXT") 386 387 q = q.replace("TODAY", f"ON {imap_date(today)}") 388 q = q.replace("YESTERDAY", f"ON {imap_date(yesterday)}") 389 390 q = q.replace("THISWEEK", f"SINCE {imap_date(week_start)}") 391 q = q.replace("THISMONTH", f"SINCE {imap_date(month_start)}") 392 q = q.replace("THISYEAR", f"SINCE {imap_date(year_start)}") 393 394 q = q.replace("LASTWEEK", imap_date_range(last_week_start, week_start)) 395 q = q.replace("LASTMONTH", imap_date_range(last_month_start, month_start)) 396 q = q.replace("LASTYEAR", imap_date_range(last_year_start, year_start)) 397 398 # shortcuts 399 q = q.replace("PASTDAY", "PAST1DAY") 400 q = q.replace("PASTWEEK", "PAST1WEEK") 401 q = q.replace("PASTMONTH", "PAST1MONTH") 402 q = q.replace("PASTYEAR", "PAST1YEAR") 403 404 # use regex to match the PASTXDAYS macro 405 q = re.sub( 406 r"PAST(\d+)DAYS?", 407 lambda m: f"SINCE {imap_date(change_time(today, days=-int(m.group(1))))}", 408 q, 409 ) 410 411 # use regex to match the PASTXWEEKS macro 412 q = re.sub( 413 r"PAST(\d+)WEEKS?", 414 lambda m: f"SINCE {imap_date(change_time(today, weeks=-int(m.group(1))))}", 415 q, 416 ) 417 418 # use regex to match the PASTXMONTHS macro 419 q = re.sub( 420 r"PAST(\d+)MONTHS?", 421 lambda m: f"SINCE {imap_date(change_time(today, days=-int(m.group(1)) * 30))}", 422 q, 423 ) 424 425 # use regex to match the PASTXYEARS macro 426 q = re.sub( 427 r"PAST(\d+)YEARS?", 428 lambda m: f"SINCE {imap_date(change_time(today, days=-int(m.group(1)) * 365))}", 429 q, 430 ) 431 432 return q 433 434 def search(self, query): 435 """Search for messages matching the query 436 437 We support extra search macros in the search query in addition to 438 the standard IMAP search macros. 439 440 One search macro is FIND <text>, which is an alias for TEXT. 441 The rest of the macros deal with date ranges. 442 443 The date range macros are expanded to the appropriate date range and 444 are relative to the current date. 445 Example: TODAY expands to ON <date>, where <date> is today's date. 446 447 Note that some of these macros will expand to multiple search terms. 448 Expansions that result in multiple search terms are wrapped in parentheses. 449 Example: LASTWEEK expands to (SINCE <date1> BEFORE <date2>). 450 451 The following extra macros are supported: 452 453 454 - FIND <text> - alias for TEXT, searches the message headers and body 455 456 Current period: 457 - TODAY - messages from today 458 - THISWEEK - messages since the start of the week, Monday to Sunday 459 - THISMONTH - messages since the start of the month 460 - THISYEAR - messages since the start of the year 461 462 Previous period: 463 - YESTERDAY - messages from yesterday 464 - LASTWEEK - messages from the week before 465 - LASTMONTH - messages from the month before 466 - LASTYEAR - messages from the year before 467 468 Periods starting from now: 469 470 _These are just shortcuts_ 471 - PASTDAY - messages from the past 1 day, same as PAST1DAY 472 - PASTWEEK - messages from the past 1 week, same as PAST1WEEK 473 - PASTMONTH - messages from the past 30 days, same as PAST1MONTH 474 - PASTYEAR - messages from the past 365 days, same as PAST1YEAR 475 476 _These are pattern matching macros_ 477 - PASTXDAYS - messages from the past X days 478 - PASTXWEEKS - messages from the past X weeks 479 - PASTXMONTHS - messages from the past X * 30 days 480 - PASTXYEARS - messages from the past X * 365 days 481 482 These macros can be combined with other search macros, and can be 483 negated with NOT. For example, to search and archive or delete messages with a short 484 relevance period, you can use `NOT PAST3DAYS`, use `NOT PAST3MONTHS` to search for 485 messages older than a quarter, or use `NOT PAST2YEAR` to search for messages older than 486 two years. 487 488 _The `NOT` modifier is very useful for mailbox maintenance_ 489 490 _There are no options for hours, because the range seletion does not have time of day precision._ 491 492 Returns: 493 bytes: A comma-separated list of message UIDs 494 """ 495 496 expanded_query = self.__expand_search_macros(query) 497 data = handle_response(self.__m.search(None, expanded_query)) 498 num_results = len(data[0].split(b" ")) 499 500 log.info(f"Searching for messages matching: {query}") 501 if expanded_query != query: 502 log.info(f"Expanded search query to: {expanded_query}") 503 log.info(f"Found {num_results} results") 504 505 return data[0].replace(b" ", b",") 506 507 def list_folders(self) -> tuple: 508 """List all folders in the mailbox 509 510 Returns: 511 tuple: A tuple of flags, delimiter, folder name, and folder display name 512 """ 513 514 folders_data = handle_response(self.__m.list()) 515 for data in folders_data: 516 flags, delimiter, folder = FOLDER_DATA_RE.match(data.decode()).groups() 517 display_name = folder.split(delimiter)[-1] 518 yield (flags, delimiter, folder, display_name) 519 520 @property 521 def current_folder(self): 522 """Get the currently selected folder""" 523 return self.__folder 524 525 def select(self, folder): 526 """Select a folder""" 527 self.__folder = folder 528 self.__m.select(folder) 529 return self 530 531 def flush(self): 532 """Write any pending changes to the disk.""" 533 pass # IMAP changes are immediate 534 535 def lock(self): 536 """Lock the mailbox.""" 537 pass # IMAP handles locking server-side 538 539 def unlock(self): 540 """Unlock the mailbox if it is locked.""" 541 pass # IMAP handles locking server-side 542 543 def close(self): 544 """Flush and close the mailbox.""" 545 self.flush() 546 self.disconnect()
195class IMAPMailbox(mailbox.Mailbox): 196 """A Mailbox class that uses an IMAPClient object as the backend""" 197 198 def __init__(self, host, user, password, folder="INBOX", port=993, security="SSL"): 199 """Create a new IMAPMailbox object""" 200 self.host = host 201 self.user = user 202 self.password = password 203 self.__folder = folder 204 self.__security = security 205 self.__port = port 206 207 def connect(self): 208 """Connect to the IMAP server""" 209 if self.__security == "SSL": 210 log.info("Connecting to IMAP server using SSL") 211 self.__m = imaplib.IMAP4_SSL(self.host, self.__port) 212 elif self.__security == "STARTTLS": 213 log.info("Connecting to IMAP server using STARTTLS") 214 self.__m = imaplib.IMAP4(self.host, self.__port) 215 self.__m.starttls() 216 elif self.__security == "PLAIN": 217 log.info("Connecting to IMAP server without encryption (insecure)") 218 self.__m = imaplib.IMAP4(self.host, self.__port) 219 else: 220 raise ValueError("Invalid security type") 221 self.__m.login(self.user, self.password) 222 self.select(self.__folder) 223 224 def disconnect(self): 225 """Disconnect from the IMAP server""" 226 227 log.info("Disconnecting from IMAP server") 228 self.__m.close() 229 self.__m.logout() 230 231 def __enter__(self): 232 self.connect() 233 return self 234 235 def __exit__(self, *args): 236 self.disconnect() 237 238 def __iter__(self): 239 """Iterate over all messages in the mailbox""" 240 data = handle_response(self.__m.search(None, "ALL")) 241 for uid in data[0].decode().split(): 242 yield IMAPMessage.from_uid(uid, self, headers_only=True) 243 244 def values(self): 245 yield from iter(self) 246 247 def keys(self) -> list[str]: 248 """Get a list of all message UIDs in the mailbox""" 249 data = handle_response(self.__m.search(None, "ALL")) 250 return data[0].decode().split() 251 252 def iterkeys(self): 253 """Return an iterator over keys.""" 254 data = handle_response(self.__m.search(None, "ALL")) 255 yield from data[0].decode().split() 256 257 def __contains__(self, key): 258 """Return True if the keyed message exists, False otherwise.""" 259 return str(key) in self.keys() 260 261 def get_bytes(self, key): 262 """Return a byte string representation or raise KeyError.""" 263 if key not in self: 264 raise KeyError(key) 265 _, body = next(self.fetch(key, "RFC822")) 266 return body 267 268 def get_file(self, key): 269 """Return a file-like representation or raise KeyError.""" 270 import io 271 272 return io.BytesIO(self.get_bytes(key)) 273 274 def get_message(self, key): 275 """Return a Message representation or raise KeyError.""" 276 if key not in self: 277 raise KeyError(key) 278 return IMAPMessage.from_uid(key, self, headers_only=False) 279 280 def __getitem__(self, key): 281 """Return the keyed message; raise KeyError if it doesn't exist.""" 282 return self.get_message(key) 283 284 def __setitem__(self, key, message): 285 """Replace the keyed message; raise KeyError if it doesn't exist.""" 286 if key not in self: 287 raise KeyError(key) 288 self.remove(key) 289 self.add(message) 290 291 def items(self): 292 """Iterate over all messages as (uid, message) tuples""" 293 data = handle_response(self.__m.search(None, "ALL")) 294 for uid in data[0].decode().split(): 295 msg = IMAPMessage.from_uid(uid, self, headers_only=True) 296 yield uid, msg 297 298 @property 299 def capability(self): 300 """Get the server capabilities""" 301 return handle_response(self.__m.capability())[0].decode() 302 303 def add(self, message): 304 """Add a message to the mailbox""" 305 306 self.__m.append( 307 self.current_folder, 308 "", 309 imaplib.Time2Internaldate(time.time()), 310 message.as_bytes(), 311 ) 312 313 def copy(self, messageset: bytes, folder: str) -> None: 314 """Copy a message to a different folder""" 315 316 self.__m.copy(messageset, folder) 317 318 def move(self, messageset: bytes, folder: str) -> None: 319 """Move a message to a different folder""" 320 321 self.__m._simple_command("MOVE", messageset, folder) 322 323 def remove(self, key): 324 """Remove the keyed message; raise KeyError if it doesn't exist.""" 325 if key not in self: 326 raise KeyError(key) 327 self.__m.store(key, "+FLAGS", "\\Deleted") 328 self.__m.expunge() 329 330 def discard(self, key): 331 """If the keyed message exists, remove it.""" 332 try: 333 self.remove(key) 334 except KeyError: 335 pass 336 337 def __delitem__(self, key): 338 """Remove the keyed message; raise KeyError if it doesn't exist.""" 339 self.remove(key) 340 341 def clear(self): 342 """Remove all messages from the mailbox.""" 343 for key in self.keys(): 344 self.__m.store(key, "+FLAGS", "\\Deleted") 345 self.__m.expunge() 346 347 def __len__(self) -> int: 348 return len(self.keys()) 349 350 def fetch(self, messageset: bytes, what): 351 """Fetch messages from the mailbox""" 352 353 response = handle_response(self.__m.fetch(messageset, what)) 354 355 # Filter response to only include message data (tuples), not FLAGS (bytes) 356 messages = [item for item in response if isinstance(item, tuple)] 357 358 for head, body in messages: 359 uid, what, size = MESSAGE_HEAD_RE.match(head.decode()).groups() 360 if size != str(len(body)): 361 raise IMAPError("Size mismatch") 362 363 yield uid, body 364 365 def __expand_search_macros(self, query) -> str: 366 """Expand search macros in the query.""" 367 368 today = datetime.date.today() 369 yesterday = today - datetime.timedelta(days=1) 370 371 week_start = today - datetime.timedelta(days=today.weekday()) 372 last_week_start = week_start - datetime.timedelta(days=7) 373 374 month_start = datetime.date(today.year, today.month, 1) 375 year_start = datetime.date(today.year, 1, 1) 376 377 if today.month == 1: # January 378 # last month is December of the previous year 379 last_month_start = datetime.date(today.year - 1, 12, 1) 380 else: 381 last_month_start = datetime.date(today.year, today.month - 1, 1) 382 383 last_year_start = datetime.date(today.year - 1, 1, 1) 384 385 q = query 386 q = q.replace("FIND", "TEXT") 387 388 q = q.replace("TODAY", f"ON {imap_date(today)}") 389 q = q.replace("YESTERDAY", f"ON {imap_date(yesterday)}") 390 391 q = q.replace("THISWEEK", f"SINCE {imap_date(week_start)}") 392 q = q.replace("THISMONTH", f"SINCE {imap_date(month_start)}") 393 q = q.replace("THISYEAR", f"SINCE {imap_date(year_start)}") 394 395 q = q.replace("LASTWEEK", imap_date_range(last_week_start, week_start)) 396 q = q.replace("LASTMONTH", imap_date_range(last_month_start, month_start)) 397 q = q.replace("LASTYEAR", imap_date_range(last_year_start, year_start)) 398 399 # shortcuts 400 q = q.replace("PASTDAY", "PAST1DAY") 401 q = q.replace("PASTWEEK", "PAST1WEEK") 402 q = q.replace("PASTMONTH", "PAST1MONTH") 403 q = q.replace("PASTYEAR", "PAST1YEAR") 404 405 # use regex to match the PASTXDAYS macro 406 q = re.sub( 407 r"PAST(\d+)DAYS?", 408 lambda m: f"SINCE {imap_date(change_time(today, days=-int(m.group(1))))}", 409 q, 410 ) 411 412 # use regex to match the PASTXWEEKS macro 413 q = re.sub( 414 r"PAST(\d+)WEEKS?", 415 lambda m: f"SINCE {imap_date(change_time(today, weeks=-int(m.group(1))))}", 416 q, 417 ) 418 419 # use regex to match the PASTXMONTHS macro 420 q = re.sub( 421 r"PAST(\d+)MONTHS?", 422 lambda m: f"SINCE {imap_date(change_time(today, days=-int(m.group(1)) * 30))}", 423 q, 424 ) 425 426 # use regex to match the PASTXYEARS macro 427 q = re.sub( 428 r"PAST(\d+)YEARS?", 429 lambda m: f"SINCE {imap_date(change_time(today, days=-int(m.group(1)) * 365))}", 430 q, 431 ) 432 433 return q 434 435 def search(self, query): 436 """Search for messages matching the query 437 438 We support extra search macros in the search query in addition to 439 the standard IMAP search macros. 440 441 One search macro is FIND <text>, which is an alias for TEXT. 442 The rest of the macros deal with date ranges. 443 444 The date range macros are expanded to the appropriate date range and 445 are relative to the current date. 446 Example: TODAY expands to ON <date>, where <date> is today's date. 447 448 Note that some of these macros will expand to multiple search terms. 449 Expansions that result in multiple search terms are wrapped in parentheses. 450 Example: LASTWEEK expands to (SINCE <date1> BEFORE <date2>). 451 452 The following extra macros are supported: 453 454 455 - FIND <text> - alias for TEXT, searches the message headers and body 456 457 Current period: 458 - TODAY - messages from today 459 - THISWEEK - messages since the start of the week, Monday to Sunday 460 - THISMONTH - messages since the start of the month 461 - THISYEAR - messages since the start of the year 462 463 Previous period: 464 - YESTERDAY - messages from yesterday 465 - LASTWEEK - messages from the week before 466 - LASTMONTH - messages from the month before 467 - LASTYEAR - messages from the year before 468 469 Periods starting from now: 470 471 _These are just shortcuts_ 472 - PASTDAY - messages from the past 1 day, same as PAST1DAY 473 - PASTWEEK - messages from the past 1 week, same as PAST1WEEK 474 - PASTMONTH - messages from the past 30 days, same as PAST1MONTH 475 - PASTYEAR - messages from the past 365 days, same as PAST1YEAR 476 477 _These are pattern matching macros_ 478 - PASTXDAYS - messages from the past X days 479 - PASTXWEEKS - messages from the past X weeks 480 - PASTXMONTHS - messages from the past X * 30 days 481 - PASTXYEARS - messages from the past X * 365 days 482 483 These macros can be combined with other search macros, and can be 484 negated with NOT. For example, to search and archive or delete messages with a short 485 relevance period, you can use `NOT PAST3DAYS`, use `NOT PAST3MONTHS` to search for 486 messages older than a quarter, or use `NOT PAST2YEAR` to search for messages older than 487 two years. 488 489 _The `NOT` modifier is very useful for mailbox maintenance_ 490 491 _There are no options for hours, because the range seletion does not have time of day precision._ 492 493 Returns: 494 bytes: A comma-separated list of message UIDs 495 """ 496 497 expanded_query = self.__expand_search_macros(query) 498 data = handle_response(self.__m.search(None, expanded_query)) 499 num_results = len(data[0].split(b" ")) 500 501 log.info(f"Searching for messages matching: {query}") 502 if expanded_query != query: 503 log.info(f"Expanded search query to: {expanded_query}") 504 log.info(f"Found {num_results} results") 505 506 return data[0].replace(b" ", b",") 507 508 def list_folders(self) -> tuple: 509 """List all folders in the mailbox 510 511 Returns: 512 tuple: A tuple of flags, delimiter, folder name, and folder display name 513 """ 514 515 folders_data = handle_response(self.__m.list()) 516 for data in folders_data: 517 flags, delimiter, folder = FOLDER_DATA_RE.match(data.decode()).groups() 518 display_name = folder.split(delimiter)[-1] 519 yield (flags, delimiter, folder, display_name) 520 521 @property 522 def current_folder(self): 523 """Get the currently selected folder""" 524 return self.__folder 525 526 def select(self, folder): 527 """Select a folder""" 528 self.__folder = folder 529 self.__m.select(folder) 530 return self 531 532 def flush(self): 533 """Write any pending changes to the disk.""" 534 pass # IMAP changes are immediate 535 536 def lock(self): 537 """Lock the mailbox.""" 538 pass # IMAP handles locking server-side 539 540 def unlock(self): 541 """Unlock the mailbox if it is locked.""" 542 pass # IMAP handles locking server-side 543 544 def close(self): 545 """Flush and close the mailbox.""" 546 self.flush() 547 self.disconnect()
A Mailbox class that uses an IMAPClient object as the backend
198 def __init__(self, host, user, password, folder="INBOX", port=993, security="SSL"): 199 """Create a new IMAPMailbox object""" 200 self.host = host 201 self.user = user 202 self.password = password 203 self.__folder = folder 204 self.__security = security 205 self.__port = port
Create a new IMAPMailbox object
207 def connect(self): 208 """Connect to the IMAP server""" 209 if self.__security == "SSL": 210 log.info("Connecting to IMAP server using SSL") 211 self.__m = imaplib.IMAP4_SSL(self.host, self.__port) 212 elif self.__security == "STARTTLS": 213 log.info("Connecting to IMAP server using STARTTLS") 214 self.__m = imaplib.IMAP4(self.host, self.__port) 215 self.__m.starttls() 216 elif self.__security == "PLAIN": 217 log.info("Connecting to IMAP server without encryption (insecure)") 218 self.__m = imaplib.IMAP4(self.host, self.__port) 219 else: 220 raise ValueError("Invalid security type") 221 self.__m.login(self.user, self.password) 222 self.select(self.__folder)
Connect to the IMAP server
224 def disconnect(self): 225 """Disconnect from the IMAP server""" 226 227 log.info("Disconnecting from IMAP server") 228 self.__m.close() 229 self.__m.logout()
Disconnect from the IMAP server
247 def keys(self) -> list[str]: 248 """Get a list of all message UIDs in the mailbox""" 249 data = handle_response(self.__m.search(None, "ALL")) 250 return data[0].decode().split()
Get a list of all message UIDs in the mailbox
252 def iterkeys(self): 253 """Return an iterator over keys.""" 254 data = handle_response(self.__m.search(None, "ALL")) 255 yield from data[0].decode().split()
Return an iterator over keys.
261 def get_bytes(self, key): 262 """Return a byte string representation or raise KeyError.""" 263 if key not in self: 264 raise KeyError(key) 265 _, body = next(self.fetch(key, "RFC822")) 266 return body
Return a byte string representation or raise KeyError.
268 def get_file(self, key): 269 """Return a file-like representation or raise KeyError.""" 270 import io 271 272 return io.BytesIO(self.get_bytes(key))
Return a file-like representation or raise KeyError.
274 def get_message(self, key): 275 """Return a Message representation or raise KeyError.""" 276 if key not in self: 277 raise KeyError(key) 278 return IMAPMessage.from_uid(key, self, headers_only=False)
Return a Message representation or raise KeyError.
291 def items(self): 292 """Iterate over all messages as (uid, message) tuples""" 293 data = handle_response(self.__m.search(None, "ALL")) 294 for uid in data[0].decode().split(): 295 msg = IMAPMessage.from_uid(uid, self, headers_only=True) 296 yield uid, msg
Iterate over all messages as (uid, message) tuples
298 @property 299 def capability(self): 300 """Get the server capabilities""" 301 return handle_response(self.__m.capability())[0].decode()
Get the server capabilities
303 def add(self, message): 304 """Add a message to the mailbox""" 305 306 self.__m.append( 307 self.current_folder, 308 "", 309 imaplib.Time2Internaldate(time.time()), 310 message.as_bytes(), 311 )
Add a message to the mailbox
313 def copy(self, messageset: bytes, folder: str) -> None: 314 """Copy a message to a different folder""" 315 316 self.__m.copy(messageset, folder)
Copy a message to a different folder
318 def move(self, messageset: bytes, folder: str) -> None: 319 """Move a message to a different folder""" 320 321 self.__m._simple_command("MOVE", messageset, folder)
Move a message to a different folder
323 def remove(self, key): 324 """Remove the keyed message; raise KeyError if it doesn't exist.""" 325 if key not in self: 326 raise KeyError(key) 327 self.__m.store(key, "+FLAGS", "\\Deleted") 328 self.__m.expunge()
Remove the keyed message; raise KeyError if it doesn't exist.
330 def discard(self, key): 331 """If the keyed message exists, remove it.""" 332 try: 333 self.remove(key) 334 except KeyError: 335 pass
If the keyed message exists, remove it.
341 def clear(self): 342 """Remove all messages from the mailbox.""" 343 for key in self.keys(): 344 self.__m.store(key, "+FLAGS", "\\Deleted") 345 self.__m.expunge()
Remove all messages from the mailbox.
350 def fetch(self, messageset: bytes, what): 351 """Fetch messages from the mailbox""" 352 353 response = handle_response(self.__m.fetch(messageset, what)) 354 355 # Filter response to only include message data (tuples), not FLAGS (bytes) 356 messages = [item for item in response if isinstance(item, tuple)] 357 358 for head, body in messages: 359 uid, what, size = MESSAGE_HEAD_RE.match(head.decode()).groups() 360 if size != str(len(body)): 361 raise IMAPError("Size mismatch") 362 363 yield uid, body
Fetch messages from the mailbox
435 def search(self, query): 436 """Search for messages matching the query 437 438 We support extra search macros in the search query in addition to 439 the standard IMAP search macros. 440 441 One search macro is FIND <text>, which is an alias for TEXT. 442 The rest of the macros deal with date ranges. 443 444 The date range macros are expanded to the appropriate date range and 445 are relative to the current date. 446 Example: TODAY expands to ON <date>, where <date> is today's date. 447 448 Note that some of these macros will expand to multiple search terms. 449 Expansions that result in multiple search terms are wrapped in parentheses. 450 Example: LASTWEEK expands to (SINCE <date1> BEFORE <date2>). 451 452 The following extra macros are supported: 453 454 455 - FIND <text> - alias for TEXT, searches the message headers and body 456 457 Current period: 458 - TODAY - messages from today 459 - THISWEEK - messages since the start of the week, Monday to Sunday 460 - THISMONTH - messages since the start of the month 461 - THISYEAR - messages since the start of the year 462 463 Previous period: 464 - YESTERDAY - messages from yesterday 465 - LASTWEEK - messages from the week before 466 - LASTMONTH - messages from the month before 467 - LASTYEAR - messages from the year before 468 469 Periods starting from now: 470 471 _These are just shortcuts_ 472 - PASTDAY - messages from the past 1 day, same as PAST1DAY 473 - PASTWEEK - messages from the past 1 week, same as PAST1WEEK 474 - PASTMONTH - messages from the past 30 days, same as PAST1MONTH 475 - PASTYEAR - messages from the past 365 days, same as PAST1YEAR 476 477 _These are pattern matching macros_ 478 - PASTXDAYS - messages from the past X days 479 - PASTXWEEKS - messages from the past X weeks 480 - PASTXMONTHS - messages from the past X * 30 days 481 - PASTXYEARS - messages from the past X * 365 days 482 483 These macros can be combined with other search macros, and can be 484 negated with NOT. For example, to search and archive or delete messages with a short 485 relevance period, you can use `NOT PAST3DAYS`, use `NOT PAST3MONTHS` to search for 486 messages older than a quarter, or use `NOT PAST2YEAR` to search for messages older than 487 two years. 488 489 _The `NOT` modifier is very useful for mailbox maintenance_ 490 491 _There are no options for hours, because the range seletion does not have time of day precision._ 492 493 Returns: 494 bytes: A comma-separated list of message UIDs 495 """ 496 497 expanded_query = self.__expand_search_macros(query) 498 data = handle_response(self.__m.search(None, expanded_query)) 499 num_results = len(data[0].split(b" ")) 500 501 log.info(f"Searching for messages matching: {query}") 502 if expanded_query != query: 503 log.info(f"Expanded search query to: {expanded_query}") 504 log.info(f"Found {num_results} results") 505 506 return data[0].replace(b" ", b",")
Search for messages matching the query
We support extra search macros in the search query in addition to the standard IMAP search macros.
One search macro is FIND
The date range macros are expanded to the appropriate date range and
are relative to the current date.
Example: TODAY expands to ON
Note that some of these macros will expand to multiple search terms.
Expansions that result in multiple search terms are wrapped in parentheses.
Example: LASTWEEK expands to (SINCE
The following extra macros are supported:
- FIND
- alias for TEXT, searches the message headers and body
Current period:
- TODAY - messages from today
- THISWEEK - messages since the start of the week, Monday to Sunday
- THISMONTH - messages since the start of the month
- THISYEAR - messages since the start of the year
Previous period:
- YESTERDAY - messages from yesterday
- LASTWEEK - messages from the week before
- LASTMONTH - messages from the month before
- LASTYEAR - messages from the year before
Periods starting from now:
_These are just shortcuts_
- PASTDAY - messages from the past 1 day, same as PAST1DAY
- PASTWEEK - messages from the past 1 week, same as PAST1WEEK
- PASTMONTH - messages from the past 30 days, same as PAST1MONTH
- PASTYEAR - messages from the past 365 days, same as PAST1YEAR
_These are pattern matching macros_
- PASTXDAYS - messages from the past X days
- PASTXWEEKS - messages from the past X weeks
- PASTXMONTHS - messages from the past X * 30 days
- PASTXYEARS - messages from the past X * 365 days
These macros can be combined with other search macros, and can be
negated with NOT. For example, to search and archive or delete messages with a short
relevance period, you can use NOT PAST3DAYS, use NOT PAST3MONTHS to search for
messages older than a quarter, or use NOT PAST2YEAR to search for messages older than
two years.
_The NOT modifier is very useful for mailbox maintenance_
_There are no options for hours, because the range seletion does not have time of day precision._
Returns: bytes: A comma-separated list of message UIDs
508 def list_folders(self) -> tuple: 509 """List all folders in the mailbox 510 511 Returns: 512 tuple: A tuple of flags, delimiter, folder name, and folder display name 513 """ 514 515 folders_data = handle_response(self.__m.list()) 516 for data in folders_data: 517 flags, delimiter, folder = FOLDER_DATA_RE.match(data.decode()).groups() 518 display_name = folder.split(delimiter)[-1] 519 yield (flags, delimiter, folder, display_name)
List all folders in the mailbox
Returns: tuple: A tuple of flags, delimiter, folder name, and folder display name
521 @property 522 def current_folder(self): 523 """Get the currently selected folder""" 524 return self.__folder
Get the currently selected folder
526 def select(self, folder): 527 """Select a folder""" 528 self.__folder = folder 529 self.__m.select(folder) 530 return self
Select a folder
532 def flush(self): 533 """Write any pending changes to the disk.""" 534 pass # IMAP changes are immediate
Write any pending changes to the disk.
58class IMAPMessage(mailbox.Message): 59 """A Mailbox Message class that uses an IMAPClient object to fetch the message 60 61 Supports lazy loading: messages can be created with headers only, and the full 62 body is fetched transparently when accessed. 63 """ 64 65 def __init__(self, message=None, uid=None, mailbox_ref=None): 66 """Create a new IMAPMessage 67 68 Args: 69 message: Email message bytes or Message object 70 uid: Message UID for lazy loading (optional) 71 mailbox_ref: Reference to IMAPMailbox for lazy loading (optional) 72 """ 73 super().__init__(message) 74 self._uid = uid 75 self._mailbox_ref = mailbox_ref 76 self._body_loaded = ( 77 mailbox_ref is None 78 ) # If no mailbox ref, body is already loaded 79 80 @classmethod 81 def from_uid(cls, uid, mailbox, headers_only=False): 82 """Create a new message from a UID 83 84 Args: 85 uid: Message UID 86 mailbox: IMAPMailbox instance 87 headers_only: If True, fetch only headers for lazy loading 88 """ 89 if headers_only: 90 # Fetch headers only, store reference for lazy body loading 91 _, body = next(mailbox.fetch(uid, "RFC822.HEADER")) 92 return cls(body, uid=uid, mailbox_ref=mailbox) 93 else: 94 # Fetch full message immediately 95 _, body = next(mailbox.fetch(uid, "RFC822")) 96 return cls(body, uid=uid) 97 98 @property 99 def uid(self): 100 """Get the message UID""" 101 return self._uid 102 103 def _ensure_body_loaded(self): 104 """Ensure the full message body is loaded 105 106 If the message was created with headers_only=True, this will fetch 107 the full message from the IMAP server. 108 109 Raises: 110 RuntimeError: If the IMAP connection is closed 111 """ 112 if self._body_loaded: 113 return 114 115 if self._mailbox_ref is None: 116 raise RuntimeError("Cannot load body: IMAP connection is closed") 117 118 # Fetch the full message 119 _, body = next(self._mailbox_ref.fetch(self._uid, "RFC822")) 120 121 # Parse the full message 122 full_msg = email.message_from_bytes(body) 123 124 # Update our payload from the parsed message 125 self._payload = full_msg._payload 126 127 # Clear the mailbox reference to allow garbage collection 128 self._mailbox_ref = None 129 self._body_loaded = True 130 131 def __getitem__(self, name: str): 132 """Get a message header 133 134 This method overrides the default implementation of accessing a message headers. 135 The header is decoded using the email.header.decode_header method. This allows 136 for the retrieval of headers that contain non-ASCII characters. 137 """ 138 original_header = super().__getitem__(name) 139 140 if original_header is None: 141 return None 142 143 decoded_pairs = email.header.decode_header(original_header) 144 decoded_chunks = [] 145 for data, charset in decoded_pairs: 146 if isinstance(data, str): 147 decoded_chunks.append(data) 148 elif charset is None: 149 decoded_chunks.append(data.decode()) 150 elif charset == "unknown-8bit": 151 decoded_chunks.append(data.decode("utf-8", "replace")) 152 else: 153 decoded_chunks.append(data.decode(charset, "replace")) 154 155 return " ".join(decoded_chunks) 156 157 # Override body-accessing methods to ensure body is loaded 158 159 def get_payload(self, *args, **kwargs): 160 """Get the message payload, ensuring body is loaded""" 161 self._ensure_body_loaded() 162 return super().get_payload(*args, **kwargs) 163 164 def is_multipart(self): 165 """Check if message is multipart, ensuring body is loaded""" 166 self._ensure_body_loaded() 167 return super().is_multipart() 168 169 def walk(self): 170 """Walk the message tree, ensuring body is loaded""" 171 self._ensure_body_loaded() 172 return super().walk() 173 174 def as_string(self, *args, **kwargs): 175 """Return message as string, ensuring body is loaded""" 176 self._ensure_body_loaded() 177 return super().as_string(*args, **kwargs) 178 179 def as_bytes(self, *args, **kwargs): 180 """Return message as bytes, ensuring body is loaded""" 181 self._ensure_body_loaded() 182 return super().as_bytes(*args, **kwargs) 183 184 def set_payload(self, *args, **kwargs): 185 """Set the message payload, ensuring body is loaded""" 186 self._ensure_body_loaded() 187 return super().set_payload(*args, **kwargs) 188 189 def attach(self, *args, **kwargs): 190 """Attach a payload, ensuring body is loaded""" 191 self._ensure_body_loaded() 192 return super().attach(*args, **kwargs)
A Mailbox Message class that uses an IMAPClient object to fetch the message
Supports lazy loading: messages can be created with headers only, and the full body is fetched transparently when accessed.
65 def __init__(self, message=None, uid=None, mailbox_ref=None): 66 """Create a new IMAPMessage 67 68 Args: 69 message: Email message bytes or Message object 70 uid: Message UID for lazy loading (optional) 71 mailbox_ref: Reference to IMAPMailbox for lazy loading (optional) 72 """ 73 super().__init__(message) 74 self._uid = uid 75 self._mailbox_ref = mailbox_ref 76 self._body_loaded = ( 77 mailbox_ref is None 78 ) # If no mailbox ref, body is already loaded
Create a new IMAPMessage
Args: message: Email message bytes or Message object uid: Message UID for lazy loading (optional) mailbox_ref: Reference to IMAPMailbox for lazy loading (optional)
80 @classmethod 81 def from_uid(cls, uid, mailbox, headers_only=False): 82 """Create a new message from a UID 83 84 Args: 85 uid: Message UID 86 mailbox: IMAPMailbox instance 87 headers_only: If True, fetch only headers for lazy loading 88 """ 89 if headers_only: 90 # Fetch headers only, store reference for lazy body loading 91 _, body = next(mailbox.fetch(uid, "RFC822.HEADER")) 92 return cls(body, uid=uid, mailbox_ref=mailbox) 93 else: 94 # Fetch full message immediately 95 _, body = next(mailbox.fetch(uid, "RFC822")) 96 return cls(body, uid=uid)
Create a new message from a UID
Args: uid: Message UID mailbox: IMAPMailbox instance headers_only: If True, fetch only headers for lazy loading
159 def get_payload(self, *args, **kwargs): 160 """Get the message payload, ensuring body is loaded""" 161 self._ensure_body_loaded() 162 return super().get_payload(*args, **kwargs)
Get the message payload, ensuring body is loaded
164 def is_multipart(self): 165 """Check if message is multipart, ensuring body is loaded""" 166 self._ensure_body_loaded() 167 return super().is_multipart()
Check if message is multipart, ensuring body is loaded
169 def walk(self): 170 """Walk the message tree, ensuring body is loaded""" 171 self._ensure_body_loaded() 172 return super().walk()
Walk the message tree, ensuring body is loaded
174 def as_string(self, *args, **kwargs): 175 """Return message as string, ensuring body is loaded""" 176 self._ensure_body_loaded() 177 return super().as_string(*args, **kwargs)
Return message as string, ensuring body is loaded
179 def as_bytes(self, *args, **kwargs): 180 """Return message as bytes, ensuring body is loaded""" 181 self._ensure_body_loaded() 182 return super().as_bytes(*args, **kwargs)
Return message as bytes, ensuring body is loaded
Exception raised for IMAP operation errors.