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

Install the latest version from GitHub:

pip install https://github.com/medecau/imap_mailbox/archive/refs/heads/main.zip

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', 'handler@proton.me')

    # erase the evidence
    mailbox.delete(uids)

_this is a joke; don't use proton for crimes – stay safe_

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)

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"""
  4import datetime
  5import email.header
  6import imaplib
  7import logging
  8import mailbox
  9import os
 10import re
 11import time
 12
 13__all__ = ["IMAPMailbox", "IMAPMessage", "IMAPMessageHeadersOnly"]
 14
 15MESSAGE_HEAD_RE = re.compile(r"(\d+) \(([^\s]+) {(\d+)}$")
 16FOLDER_DATA_RE = re.compile(r"\(([^)]+)\) \"([^\"]+)\" \"?([^\"]+)\"?$")
 17
 18
 19log = logging.getLogger(__name__)
 20log.setLevel(getattr(logging, os.getenv("LOG_LEVEL", "INFO")))
 21
 22
 23def handle_response(response):
 24    """Handle the response from the IMAP server"""
 25    status, data = response
 26    if status != "OK":
 27        raise Exception(data[0])
 28
 29    return data
 30
 31
 32def change_time(time, weeks=0, days=0, hours=0, minutes=0, seconds=0):
 33    """Change the time by a given amount of days, hours, minutes and seconds"""
 34    return time + datetime.timedelta(
 35        weeks=weeks, days=days, hours=hours, minutes=minutes, seconds=seconds
 36    )
 37
 38
 39def imap_date(time):
 40    """Convert a datetime object to an IMAP date string"""
 41    return time.strftime("%d-%b-%Y")
 42
 43
 44def imap_date_range(start, end):
 45    """Create an IMAP date range string for use in a search query"""
 46    return f"(SINCE {imap_date(start)} BEFORE {imap_date(end)})"
 47
 48
 49class IMAPMessage(mailbox.Message):
 50    """A Mailbox Message class that uses an IMAPClient object to fetch the message"""
 51
 52    @classmethod
 53    def from_uid(cls, uid, mailbox):
 54        """Create a new message from a UID"""
 55
 56        # fetch the message from the mailbox
 57        uid, body = next(mailbox.fetch(uid, "RFC822"))
 58        return cls(body)
 59
 60    def __getitem__(self, name: str):
 61        """Get a message header
 62
 63        This method overrides the default implementation of accessing a message headers.
 64        The header is decoded using the email.header.decode_header method. This allows
 65        for the retrieval of headers that contain non-ASCII characters.
 66        """
 67
 68        original_header = super().__getitem__(name)
 69
 70        if original_header is None:
 71            return None
 72
 73        decoded_pairs = email.header.decode_header(original_header)
 74        decoded_chunks = []
 75        for data, charset in decoded_pairs:
 76            if isinstance(data, str):
 77                decoded_chunks.append(data)
 78            elif charset is None:
 79                decoded_chunks.append(data.decode())
 80            elif charset == "unknown-8bit":
 81                decoded_chunks.append(data.decode("utf-8", "replace"))
 82            else:
 83                decoded_chunks.append(data.decode(charset, "replace"))
 84
 85        # decode_chunks = (pair[0] for pair in decoded_pairs)
 86
 87        return " ".join(decoded_chunks)
 88
 89
 90class IMAPMessageHeadersOnly(IMAPMessage):
 91    """A Mailbox Message class that uses an IMAPClient object to fetch the message"""
 92
 93    @classmethod
 94    def from_uid(cls, uid, mailbox):
 95        """Create a new message from a UID"""
 96
 97        # fetch headers only message from the mailbox
 98        uid, body = next(mailbox.fetch(uid, "RFC822.HEADER"))
 99        return cls(body)
100
101
102class IMAPMailbox(mailbox.Mailbox):
103    """A Mailbox class that uses an IMAPClient object as the backend"""
104
105    def __init__(self, host, user, password, folder="INBOX", port=993, security="SSL"):
106        """Create a new IMAPMailbox object"""
107        self.host = host
108        self.user = user
109        self.password = password
110        self.__folder = folder
111        self.__security = security
112        self.__port = port
113
114    def connect(self):
115        """Connect to the IMAP server"""
116        if self.__security == "SSL":
117            log.info("Connecting to IMAP server using SSL")
118            self.__m = imaplib.IMAP4_SSL(self.host, self.__port)
119        elif self.__security == "STARTTLS":
120            log.info("Connecting to IMAP server using STARTTLS")
121            self.__m = imaplib.IMAP4(self.host, self.__port)
122            self.__m.starttls()
123        else:
124            raise ValueError("Invalid security type")
125        self.__m.login(self.user, self.password)
126        self.select(self.__folder)
127
128    def disconnect(self):
129        """Disconnect from the IMAP server"""
130
131        log.info("Disconnecting from IMAP server")
132        self.__m.close()
133        self.__m.logout()
134
135    def __enter__(self):
136        self.connect()
137        return self
138
139    def __exit__(self, *args):
140        self.disconnect()
141
142    def __iter__(self):
143        """Iterate over all messages in the mailbox"""
144        data = handle_response(self.__m.search(None, "ALL"))
145        for uid in data[0].decode().split():
146            yield IMAPMessageHeadersOnly.from_uid(uid, self)
147
148    def values(self):
149        yield from iter(self)
150
151    def keys(self) -> list[str]:
152        """Get a list of all message UIDs in the mailbox"""
153        data = handle_response(self.__m.search(None, "ALL"))
154        return data[0].decode().split()
155
156    def items(self):
157        """Iterate over all messages in the mailbox"""
158        uids = ",".join(self.keys()).encode()
159        return self.fetch(uids, "RFC822")
160
161    @property
162    def capability(self):
163        """Get the server capabilities"""
164        return handle_response(self.__m.capability())[0].decode()
165
166    def add(self, message):
167        """Add a message to the mailbox"""
168
169        self.__m.append(
170            self.current_folder,
171            "",
172            imaplib.Time2Internaldate(time.time()),
173            message.as_bytes(),
174        )
175
176    def copy(self, messageset: bytes, folder: str) -> None:
177        """Copy a message to a different folder"""
178
179        self.__m.copy(messageset, folder)
180
181    def move(self, messageset: bytes, folder: str) -> None:
182        """Move a message to a different folder"""
183
184        self.__m._simple_command("MOVE", messageset, folder)
185
186    def discard(self, messageset: bytes) -> None:
187        """Mark messages for deletion"""
188
189        self.__m.store(messageset, "+FLAGS", "\\Deleted")
190
191    def remove(self, messageset: bytes) -> None:
192        """Remove messages from the mailbox"""
193
194        self.discard(messageset)
195        self.__m.expunge()
196
197    def __delitem__(self, key: str) -> None:
198        raise NotImplementedError("Use discard() instead")
199
200    def __len__(self) -> int:
201        return len(self.keys())
202
203    def fetch(self, messageset: bytes, what):
204        """Fetch messages from the mailbox"""
205
206        messages = handle_response(self.__m.fetch(messageset, what))[::2]
207
208        for head, body in messages:
209            uid, what, size = MESSAGE_HEAD_RE.match(head.decode()).groups()
210            if size != str(len(body)):
211                raise Exception("Size mismatch")
212
213            yield uid, body
214
215    def __expand_search_macros(self, query) -> str:
216        """Expand search macros in the query."""
217
218        today = datetime.date.today()
219        yesterday = today - datetime.timedelta(days=1)
220
221        week_start = today - datetime.timedelta(days=today.weekday())
222        last_week_start = week_start - datetime.timedelta(days=7)
223
224        month_start = datetime.date(today.year, today.month, 1)
225        year_start = datetime.date(today.year, 1, 1)
226
227        if today.month == 1:  # January
228            # last month is December of the previous year
229            last_month_start = datetime.date(today.year - 1, 12, 1)
230        else:
231            last_month_start = datetime.date(today.year, today.month - 1, 1)
232
233        last_year_start = datetime.date(today.year - 1, 1, 1)
234
235        q = query
236        q = q.replace("FIND", "TEXT")
237
238        q = q.replace("TODAY", f"ON {imap_date(today)}")
239        q = q.replace("YESTERDAY", f"ON {imap_date(yesterday)}")
240
241        q = q.replace("THISWEEK", f"SINCE {imap_date(week_start)}")
242        q = q.replace("THISMONTH", f"SINCE {imap_date(month_start)}")
243        q = q.replace("THISYEAR", f"SINCE {imap_date(year_start)}")
244
245        q = q.replace("LASTWEEK", imap_date_range(last_week_start, week_start))
246        q = q.replace("LASTMONTH", imap_date_range(last_month_start, month_start))
247        q = q.replace("LASTYEAR", imap_date_range(last_year_start, year_start))
248
249        # shortcuts
250        q = q.replace("PASTDAY", "PAST1DAY")
251        q = q.replace("PASTWEEK", "PAST1WEEK")
252        q = q.replace("PASTMONTH", "PAST1MONTH")
253        q = q.replace("PASTYEAR", "PAST1YEAR")
254
255        # use regex to match the PASTXDAYS macro
256        q = re.sub(
257            r"PAST(\d+)DAYS?",
258            lambda m: f"SINCE {imap_date(change_time(today, days=-int(m.group(1))))}",
259            q,
260        )
261
262        # use regex to match the PASTXWEEKS macro
263        q = re.sub(
264            r"PAST(\d+)WEEKS?",
265            lambda m: f"SINCE {imap_date(change_time(today, weeks=-int(m.group(1))))}",
266            q,
267        )
268
269        # use regex to match the PASTXMONTHS macro
270        q = re.sub(
271            r"PAST(\d+)MONTHS?",
272            lambda m: f"SINCE {imap_date(change_time(today, days=-int(m.group(1)) * 30))}",
273            q,
274        )
275
276        # use regex to match the PASTXYEARS macro
277        q = re.sub(
278            r"PAST(\d+)YEARS?",
279            lambda m: f"SINCE {imap_date(change_time(today, days=-int(m.group(1)) * 365))}",
280            q,
281        )
282
283        return q
284
285    def search(self, query):
286        """Search for messages matching the query
287
288        We support extra search macros in the search query in addition to
289        the standard IMAP search macros.
290
291        One search macro is FIND <text>, which is an alias for TEXT.
292        The rest of the macros deal with date ranges.
293
294        The date range macros are expanded to the appropriate date range and
295        are relative to the current date.
296        Example: TODAY expands to ON <date>, where <date> is today's date.
297
298        Note that some of these macros will expand to multiple search terms.
299        Expansions that result in multiple search terms are wrapped in parentheses.
300        Example: LASTWEEK expands to (SINCE <date1> BEFORE <date2>).
301
302        The following extra macros are supported:
303
304
305        - FIND <text> - alias for TEXT, searches the message headers and body
306
307        Current period:
308        - TODAY - messages from today
309        - THISWEEK - messages since the start of the week, Monday to Sunday
310        - THISMONTH - messages since the start of the month
311        - THISYEAR - messages since the start of the year
312
313        Previous period:
314        - YESTERDAY - messages from yesterday
315        - LASTWEEK - messages from the week before
316        - LASTMONTH - messages from the month before
317        - LASTYEAR - messages from the year before
318
319        Periods starting from now:
320
321        _These are just shortcuts_
322        - PASTDAY - messages from the past 1 day, same as PAST1DAY
323        - PASTWEEK - messages from the past 1 week, same as PAST1WEEK
324        - PASTMONTH - messages from the past 30 days, same as PAST1MONTH
325        - PASTYEAR - messages from the past 365 days, same as PAST1YEAR
326
327        _These are pattern matching macros_
328        - PASTXDAYS - messages from the past X days
329        - PASTXWEEKS - messages from the past X weeks
330        - PASTXMONTHS - messages from the past X * 30 days
331        - PASTXYEARS - messages from the past X * 365 days
332
333        These macros can be combined with other search macros, and can be
334        negated with NOT. For example, to search and archive or delete messages with a short
335        relevance period, you can use `NOT PAST3DAYS`, use `NOT PAST3MONTHS` to search for
336        messages older than a quarter, or use `NOT PAST2YEAR` to search for messages older than
337        two years.
338
339        _The `NOT` modifier is very useful for mailbox maintenance_
340
341        _There are no options for hours, because the range seletion does not have time of day precision._
342
343        Returns:
344            bytes: A comma-separated list of message UIDs
345        """
346
347        expanded_query = self.__expand_search_macros(query)
348        data = handle_response(self.__m.search(None, expanded_query))
349        num_results = len(data[0].split(b" "))
350
351        log.info(f"Searching for messages matching: {query}")
352        if expanded_query != query:
353            log.info(f"Expanded search query to: {expanded_query}")
354        log.info(f"Found {num_results} results")
355
356        return data[0].replace(b" ", b",")
357
358    def list_folders(self) -> tuple:
359        """List all folders in the mailbox
360
361        Returns:
362            tuple: A tuple of flags, delimiter, folder name, and folder display name
363        """
364
365        folders_data = handle_response(self.__m.list())
366        for data in folders_data:
367            flags, delimiter, folder = FOLDER_DATA_RE.match(data.decode()).groups()
368            display_name = folder.split(delimiter)[-1]
369            yield (flags, delimiter, folder, display_name)
370
371    @property
372    def current_folder(self):
373        """Get the currently selected folder"""
374        return self.__folder
375
376    def select(self, folder):
377        """Select a folder"""
378        self.__folder = folder
379        self.__m.select(folder)
380        return self
class IMAPMailbox(mailbox.Mailbox):
103class IMAPMailbox(mailbox.Mailbox):
104    """A Mailbox class that uses an IMAPClient object as the backend"""
105
106    def __init__(self, host, user, password, folder="INBOX", port=993, security="SSL"):
107        """Create a new IMAPMailbox object"""
108        self.host = host
109        self.user = user
110        self.password = password
111        self.__folder = folder
112        self.__security = security
113        self.__port = port
114
115    def connect(self):
116        """Connect to the IMAP server"""
117        if self.__security == "SSL":
118            log.info("Connecting to IMAP server using SSL")
119            self.__m = imaplib.IMAP4_SSL(self.host, self.__port)
120        elif self.__security == "STARTTLS":
121            log.info("Connecting to IMAP server using STARTTLS")
122            self.__m = imaplib.IMAP4(self.host, self.__port)
123            self.__m.starttls()
124        else:
125            raise ValueError("Invalid security type")
126        self.__m.login(self.user, self.password)
127        self.select(self.__folder)
128
129    def disconnect(self):
130        """Disconnect from the IMAP server"""
131
132        log.info("Disconnecting from IMAP server")
133        self.__m.close()
134        self.__m.logout()
135
136    def __enter__(self):
137        self.connect()
138        return self
139
140    def __exit__(self, *args):
141        self.disconnect()
142
143    def __iter__(self):
144        """Iterate over all messages in the mailbox"""
145        data = handle_response(self.__m.search(None, "ALL"))
146        for uid in data[0].decode().split():
147            yield IMAPMessageHeadersOnly.from_uid(uid, self)
148
149    def values(self):
150        yield from iter(self)
151
152    def keys(self) -> list[str]:
153        """Get a list of all message UIDs in the mailbox"""
154        data = handle_response(self.__m.search(None, "ALL"))
155        return data[0].decode().split()
156
157    def items(self):
158        """Iterate over all messages in the mailbox"""
159        uids = ",".join(self.keys()).encode()
160        return self.fetch(uids, "RFC822")
161
162    @property
163    def capability(self):
164        """Get the server capabilities"""
165        return handle_response(self.__m.capability())[0].decode()
166
167    def add(self, message):
168        """Add a message to the mailbox"""
169
170        self.__m.append(
171            self.current_folder,
172            "",
173            imaplib.Time2Internaldate(time.time()),
174            message.as_bytes(),
175        )
176
177    def copy(self, messageset: bytes, folder: str) -> None:
178        """Copy a message to a different folder"""
179
180        self.__m.copy(messageset, folder)
181
182    def move(self, messageset: bytes, folder: str) -> None:
183        """Move a message to a different folder"""
184
185        self.__m._simple_command("MOVE", messageset, folder)
186
187    def discard(self, messageset: bytes) -> None:
188        """Mark messages for deletion"""
189
190        self.__m.store(messageset, "+FLAGS", "\\Deleted")
191
192    def remove(self, messageset: bytes) -> None:
193        """Remove messages from the mailbox"""
194
195        self.discard(messageset)
196        self.__m.expunge()
197
198    def __delitem__(self, key: str) -> None:
199        raise NotImplementedError("Use discard() instead")
200
201    def __len__(self) -> int:
202        return len(self.keys())
203
204    def fetch(self, messageset: bytes, what):
205        """Fetch messages from the mailbox"""
206
207        messages = handle_response(self.__m.fetch(messageset, what))[::2]
208
209        for head, body in messages:
210            uid, what, size = MESSAGE_HEAD_RE.match(head.decode()).groups()
211            if size != str(len(body)):
212                raise Exception("Size mismatch")
213
214            yield uid, body
215
216    def __expand_search_macros(self, query) -> str:
217        """Expand search macros in the query."""
218
219        today = datetime.date.today()
220        yesterday = today - datetime.timedelta(days=1)
221
222        week_start = today - datetime.timedelta(days=today.weekday())
223        last_week_start = week_start - datetime.timedelta(days=7)
224
225        month_start = datetime.date(today.year, today.month, 1)
226        year_start = datetime.date(today.year, 1, 1)
227
228        if today.month == 1:  # January
229            # last month is December of the previous year
230            last_month_start = datetime.date(today.year - 1, 12, 1)
231        else:
232            last_month_start = datetime.date(today.year, today.month - 1, 1)
233
234        last_year_start = datetime.date(today.year - 1, 1, 1)
235
236        q = query
237        q = q.replace("FIND", "TEXT")
238
239        q = q.replace("TODAY", f"ON {imap_date(today)}")
240        q = q.replace("YESTERDAY", f"ON {imap_date(yesterday)}")
241
242        q = q.replace("THISWEEK", f"SINCE {imap_date(week_start)}")
243        q = q.replace("THISMONTH", f"SINCE {imap_date(month_start)}")
244        q = q.replace("THISYEAR", f"SINCE {imap_date(year_start)}")
245
246        q = q.replace("LASTWEEK", imap_date_range(last_week_start, week_start))
247        q = q.replace("LASTMONTH", imap_date_range(last_month_start, month_start))
248        q = q.replace("LASTYEAR", imap_date_range(last_year_start, year_start))
249
250        # shortcuts
251        q = q.replace("PASTDAY", "PAST1DAY")
252        q = q.replace("PASTWEEK", "PAST1WEEK")
253        q = q.replace("PASTMONTH", "PAST1MONTH")
254        q = q.replace("PASTYEAR", "PAST1YEAR")
255
256        # use regex to match the PASTXDAYS macro
257        q = re.sub(
258            r"PAST(\d+)DAYS?",
259            lambda m: f"SINCE {imap_date(change_time(today, days=-int(m.group(1))))}",
260            q,
261        )
262
263        # use regex to match the PASTXWEEKS macro
264        q = re.sub(
265            r"PAST(\d+)WEEKS?",
266            lambda m: f"SINCE {imap_date(change_time(today, weeks=-int(m.group(1))))}",
267            q,
268        )
269
270        # use regex to match the PASTXMONTHS macro
271        q = re.sub(
272            r"PAST(\d+)MONTHS?",
273            lambda m: f"SINCE {imap_date(change_time(today, days=-int(m.group(1)) * 30))}",
274            q,
275        )
276
277        # use regex to match the PASTXYEARS macro
278        q = re.sub(
279            r"PAST(\d+)YEARS?",
280            lambda m: f"SINCE {imap_date(change_time(today, days=-int(m.group(1)) * 365))}",
281            q,
282        )
283
284        return q
285
286    def search(self, query):
287        """Search for messages matching the query
288
289        We support extra search macros in the search query in addition to
290        the standard IMAP search macros.
291
292        One search macro is FIND <text>, which is an alias for TEXT.
293        The rest of the macros deal with date ranges.
294
295        The date range macros are expanded to the appropriate date range and
296        are relative to the current date.
297        Example: TODAY expands to ON <date>, where <date> is today's date.
298
299        Note that some of these macros will expand to multiple search terms.
300        Expansions that result in multiple search terms are wrapped in parentheses.
301        Example: LASTWEEK expands to (SINCE <date1> BEFORE <date2>).
302
303        The following extra macros are supported:
304
305
306        - FIND <text> - alias for TEXT, searches the message headers and body
307
308        Current period:
309        - TODAY - messages from today
310        - THISWEEK - messages since the start of the week, Monday to Sunday
311        - THISMONTH - messages since the start of the month
312        - THISYEAR - messages since the start of the year
313
314        Previous period:
315        - YESTERDAY - messages from yesterday
316        - LASTWEEK - messages from the week before
317        - LASTMONTH - messages from the month before
318        - LASTYEAR - messages from the year before
319
320        Periods starting from now:
321
322        _These are just shortcuts_
323        - PASTDAY - messages from the past 1 day, same as PAST1DAY
324        - PASTWEEK - messages from the past 1 week, same as PAST1WEEK
325        - PASTMONTH - messages from the past 30 days, same as PAST1MONTH
326        - PASTYEAR - messages from the past 365 days, same as PAST1YEAR
327
328        _These are pattern matching macros_
329        - PASTXDAYS - messages from the past X days
330        - PASTXWEEKS - messages from the past X weeks
331        - PASTXMONTHS - messages from the past X * 30 days
332        - PASTXYEARS - messages from the past X * 365 days
333
334        These macros can be combined with other search macros, and can be
335        negated with NOT. For example, to search and archive or delete messages with a short
336        relevance period, you can use `NOT PAST3DAYS`, use `NOT PAST3MONTHS` to search for
337        messages older than a quarter, or use `NOT PAST2YEAR` to search for messages older than
338        two years.
339
340        _The `NOT` modifier is very useful for mailbox maintenance_
341
342        _There are no options for hours, because the range seletion does not have time of day precision._
343
344        Returns:
345            bytes: A comma-separated list of message UIDs
346        """
347
348        expanded_query = self.__expand_search_macros(query)
349        data = handle_response(self.__m.search(None, expanded_query))
350        num_results = len(data[0].split(b" "))
351
352        log.info(f"Searching for messages matching: {query}")
353        if expanded_query != query:
354            log.info(f"Expanded search query to: {expanded_query}")
355        log.info(f"Found {num_results} results")
356
357        return data[0].replace(b" ", b",")
358
359    def list_folders(self) -> tuple:
360        """List all folders in the mailbox
361
362        Returns:
363            tuple: A tuple of flags, delimiter, folder name, and folder display name
364        """
365
366        folders_data = handle_response(self.__m.list())
367        for data in folders_data:
368            flags, delimiter, folder = FOLDER_DATA_RE.match(data.decode()).groups()
369            display_name = folder.split(delimiter)[-1]
370            yield (flags, delimiter, folder, display_name)
371
372    @property
373    def current_folder(self):
374        """Get the currently selected folder"""
375        return self.__folder
376
377    def select(self, folder):
378        """Select a folder"""
379        self.__folder = folder
380        self.__m.select(folder)
381        return self

A Mailbox class that uses an IMAPClient object as the backend

IMAPMailbox(host, user, password, folder='INBOX', port=993, security='SSL')
106    def __init__(self, host, user, password, folder="INBOX", port=993, security="SSL"):
107        """Create a new IMAPMailbox object"""
108        self.host = host
109        self.user = user
110        self.password = password
111        self.__folder = folder
112        self.__security = security
113        self.__port = port

Create a new IMAPMailbox object

def connect(self):
115    def connect(self):
116        """Connect to the IMAP server"""
117        if self.__security == "SSL":
118            log.info("Connecting to IMAP server using SSL")
119            self.__m = imaplib.IMAP4_SSL(self.host, self.__port)
120        elif self.__security == "STARTTLS":
121            log.info("Connecting to IMAP server using STARTTLS")
122            self.__m = imaplib.IMAP4(self.host, self.__port)
123            self.__m.starttls()
124        else:
125            raise ValueError("Invalid security type")
126        self.__m.login(self.user, self.password)
127        self.select(self.__folder)

Connect to the IMAP server

def disconnect(self):
129    def disconnect(self):
130        """Disconnect from the IMAP server"""
131
132        log.info("Disconnecting from IMAP server")
133        self.__m.close()
134        self.__m.logout()

Disconnect from the IMAP server

def values(self):
149    def values(self):
150        yield from iter(self)

Return a list of messages. Memory intensive.

def keys(self) -> list[str]:
152    def keys(self) -> list[str]:
153        """Get a list of all message UIDs in the mailbox"""
154        data = handle_response(self.__m.search(None, "ALL"))
155        return data[0].decode().split()

Get a list of all message UIDs in the mailbox

def items(self):
157    def items(self):
158        """Iterate over all messages in the mailbox"""
159        uids = ",".join(self.keys()).encode()
160        return self.fetch(uids, "RFC822")

Iterate over all messages in the mailbox

capability

Get the server capabilities

def add(self, message):
167    def add(self, message):
168        """Add a message to the mailbox"""
169
170        self.__m.append(
171            self.current_folder,
172            "",
173            imaplib.Time2Internaldate(time.time()),
174            message.as_bytes(),
175        )

Add a message to the mailbox

def copy(self, messageset: bytes, folder: str) -> None:
177    def copy(self, messageset: bytes, folder: str) -> None:
178        """Copy a message to a different folder"""
179
180        self.__m.copy(messageset, folder)

Copy a message to a different folder

def move(self, messageset: bytes, folder: str) -> None:
182    def move(self, messageset: bytes, folder: str) -> None:
183        """Move a message to a different folder"""
184
185        self.__m._simple_command("MOVE", messageset, folder)

Move a message to a different folder

def discard(self, messageset: bytes) -> None:
187    def discard(self, messageset: bytes) -> None:
188        """Mark messages for deletion"""
189
190        self.__m.store(messageset, "+FLAGS", "\\Deleted")

Mark messages for deletion

def remove(self, messageset: bytes) -> None:
192    def remove(self, messageset: bytes) -> None:
193        """Remove messages from the mailbox"""
194
195        self.discard(messageset)
196        self.__m.expunge()

Remove messages from the mailbox

def fetch(self, messageset: bytes, what):
204    def fetch(self, messageset: bytes, what):
205        """Fetch messages from the mailbox"""
206
207        messages = handle_response(self.__m.fetch(messageset, what))[::2]
208
209        for head, body in messages:
210            uid, what, size = MESSAGE_HEAD_RE.match(head.decode()).groups()
211            if size != str(len(body)):
212                raise Exception("Size mismatch")
213
214            yield uid, body

Fetch messages from the mailbox

def search(self, query):
286    def search(self, query):
287        """Search for messages matching the query
288
289        We support extra search macros in the search query in addition to
290        the standard IMAP search macros.
291
292        One search macro is FIND <text>, which is an alias for TEXT.
293        The rest of the macros deal with date ranges.
294
295        The date range macros are expanded to the appropriate date range and
296        are relative to the current date.
297        Example: TODAY expands to ON <date>, where <date> is today's date.
298
299        Note that some of these macros will expand to multiple search terms.
300        Expansions that result in multiple search terms are wrapped in parentheses.
301        Example: LASTWEEK expands to (SINCE <date1> BEFORE <date2>).
302
303        The following extra macros are supported:
304
305
306        - FIND <text> - alias for TEXT, searches the message headers and body
307
308        Current period:
309        - TODAY - messages from today
310        - THISWEEK - messages since the start of the week, Monday to Sunday
311        - THISMONTH - messages since the start of the month
312        - THISYEAR - messages since the start of the year
313
314        Previous period:
315        - YESTERDAY - messages from yesterday
316        - LASTWEEK - messages from the week before
317        - LASTMONTH - messages from the month before
318        - LASTYEAR - messages from the year before
319
320        Periods starting from now:
321
322        _These are just shortcuts_
323        - PASTDAY - messages from the past 1 day, same as PAST1DAY
324        - PASTWEEK - messages from the past 1 week, same as PAST1WEEK
325        - PASTMONTH - messages from the past 30 days, same as PAST1MONTH
326        - PASTYEAR - messages from the past 365 days, same as PAST1YEAR
327
328        _These are pattern matching macros_
329        - PASTXDAYS - messages from the past X days
330        - PASTXWEEKS - messages from the past X weeks
331        - PASTXMONTHS - messages from the past X * 30 days
332        - PASTXYEARS - messages from the past X * 365 days
333
334        These macros can be combined with other search macros, and can be
335        negated with NOT. For example, to search and archive or delete messages with a short
336        relevance period, you can use `NOT PAST3DAYS`, use `NOT PAST3MONTHS` to search for
337        messages older than a quarter, or use `NOT PAST2YEAR` to search for messages older than
338        two years.
339
340        _The `NOT` modifier is very useful for mailbox maintenance_
341
342        _There are no options for hours, because the range seletion does not have time of day precision._
343
344        Returns:
345            bytes: A comma-separated list of message UIDs
346        """
347
348        expanded_query = self.__expand_search_macros(query)
349        data = handle_response(self.__m.search(None, expanded_query))
350        num_results = len(data[0].split(b" "))
351
352        log.info(f"Searching for messages matching: {query}")
353        if expanded_query != query:
354            log.info(f"Expanded search query to: {expanded_query}")
355        log.info(f"Found {num_results} results")
356
357        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 , which is an alias for TEXT. The rest of the macros deal with date ranges.

The date range macros are expanded to the appropriate date range and are relative to the current date. Example: TODAY expands to ON , where is today's date.

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 BEFORE ).

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

def list_folders(self) -> tuple:
359    def list_folders(self) -> tuple:
360        """List all folders in the mailbox
361
362        Returns:
363            tuple: A tuple of flags, delimiter, folder name, and folder display name
364        """
365
366        folders_data = handle_response(self.__m.list())
367        for data in folders_data:
368            flags, delimiter, folder = FOLDER_DATA_RE.match(data.decode()).groups()
369            display_name = folder.split(delimiter)[-1]
370            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

current_folder

Get the currently selected folder

def select(self, folder):
377    def select(self, folder):
378        """Select a folder"""
379        self.__folder = folder
380        self.__m.select(folder)
381        return self

Select a folder

Inherited Members
mailbox.Mailbox
get
get_message
get_string
get_bytes
get_file
iterkeys
itervalues
iteritems
clear
pop
popitem
update
flush
lock
unlock
close
class IMAPMessage(mailbox.Message):
50class IMAPMessage(mailbox.Message):
51    """A Mailbox Message class that uses an IMAPClient object to fetch the message"""
52
53    @classmethod
54    def from_uid(cls, uid, mailbox):
55        """Create a new message from a UID"""
56
57        # fetch the message from the mailbox
58        uid, body = next(mailbox.fetch(uid, "RFC822"))
59        return cls(body)
60
61    def __getitem__(self, name: str):
62        """Get a message header
63
64        This method overrides the default implementation of accessing a message headers.
65        The header is decoded using the email.header.decode_header method. This allows
66        for the retrieval of headers that contain non-ASCII characters.
67        """
68
69        original_header = super().__getitem__(name)
70
71        if original_header is None:
72            return None
73
74        decoded_pairs = email.header.decode_header(original_header)
75        decoded_chunks = []
76        for data, charset in decoded_pairs:
77            if isinstance(data, str):
78                decoded_chunks.append(data)
79            elif charset is None:
80                decoded_chunks.append(data.decode())
81            elif charset == "unknown-8bit":
82                decoded_chunks.append(data.decode("utf-8", "replace"))
83            else:
84                decoded_chunks.append(data.decode(charset, "replace"))
85
86        # decode_chunks = (pair[0] for pair in decoded_pairs)
87
88        return " ".join(decoded_chunks)

A Mailbox Message class that uses an IMAPClient object to fetch the message

@classmethod
def from_uid(cls, uid, mailbox):
53    @classmethod
54    def from_uid(cls, uid, mailbox):
55        """Create a new message from a UID"""
56
57        # fetch the message from the mailbox
58        uid, body = next(mailbox.fetch(uid, "RFC822"))
59        return cls(body)

Create a new message from a UID

Inherited Members
mailbox.Message
Message
email.message.Message
as_string
as_bytes
is_multipart
set_unixfrom
get_unixfrom
attach
get_payload
set_payload
set_charset
get_charset
keys
values
items
get
set_raw
raw_items
get_all
add_header
replace_header
get_content_type
get_content_maintype
get_content_subtype
get_default_type
set_default_type
get_params
get_param
set_param
del_param
set_type
get_filename
get_boundary
set_boundary
get_content_charset
get_charsets
get_content_disposition
walk
class IMAPMessageHeadersOnly(IMAPMessage):
 91class IMAPMessageHeadersOnly(IMAPMessage):
 92    """A Mailbox Message class that uses an IMAPClient object to fetch the message"""
 93
 94    @classmethod
 95    def from_uid(cls, uid, mailbox):
 96        """Create a new message from a UID"""
 97
 98        # fetch headers only message from the mailbox
 99        uid, body = next(mailbox.fetch(uid, "RFC822.HEADER"))
100        return cls(body)

A Mailbox Message class that uses an IMAPClient object to fetch the message

@classmethod
def from_uid(cls, uid, mailbox):
 94    @classmethod
 95    def from_uid(cls, uid, mailbox):
 96        """Create a new message from a UID"""
 97
 98        # fetch headers only message from the mailbox
 99        uid, body = next(mailbox.fetch(uid, "RFC822.HEADER"))
100        return cls(body)

Create a new message from a UID

Inherited Members
mailbox.Message
Message
email.message.Message
as_string
as_bytes
is_multipart
set_unixfrom
get_unixfrom
attach
get_payload
set_payload
set_charset
get_charset
keys
values
items
get
set_raw
raw_items
get_all
add_header
replace_header
get_content_type
get_content_maintype
get_content_subtype
get_default_type
set_default_type
get_params
get_param
set_param
del_param
set_type
get_filename
get_boundary
set_boundary
get_content_charset
get_charsets
get_content_disposition
walk