Skip to content

Commit 44f72c6

Browse files
committed
+BaseMailBox.(copy,move,flag,delete) chunks arg
1 parent 2888a4b commit 44f72c6

File tree

10 files changed

+188
-91
lines changed

10 files changed

+188
-91
lines changed

README.rst

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ Action's uid_list arg may takes:
238238
To get uids, use the maibox methods: uids, fetch.
239239

240240
For actions with a large number of messages imap command may be too large and will cause exception at server side,
241-
use 'limit' argument for fetch in this case.
241+
use ``chunks`` argument for ``copy,move,delete,flag`` OR ``limit`` argument for ``fetch`` in this case.
242242

243243
.. code-block:: python
244244
@@ -247,8 +247,8 @@ use 'limit' argument for fetch in this case.
247247
# COPY messages with uid in 23,27 from current folder to folder1
248248
mailbox.copy('23,27', 'folder1')
249249
250-
# MOVE all messages from current folder to INBOX/folder2
251-
mailbox.move(mailbox.uids(), 'INBOX/folder2')
250+
# MOVE all messages from current folder to INBOX/folder2, move by 100 emails at once
251+
mailbox.move(mailbox.uids(), 'INBOX/folder2', chunks=100)
252252
253253
# DELETE messages with 'cat' word in its html from current folder
254254
mailbox.delete([msg.uid for msg in mailbox.fetch() if 'cat' in msg.html])
@@ -429,7 +429,8 @@ Big thanks to people who helped develop this library:
429429
`homoLudenus <https://github.com/homoLudenus>`_,
430430
`sphh <https://github.com/sphh>`_,
431431
`bh <https://github.com/bh>`_,
432-
`tomasmach <https://github.com/tomasmach>`_
432+
`tomasmach <https://github.com/tomasmach>`_,
433+
`errror <https://github.com/errror>`_
433434

434435
Help the project
435436
----------------

docs/release_notes.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
1.10.0
2+
======
3+
* Added: support IMAP command MOVE at BaseMailBox.move
4+
* Added: MailboxMoveError, raises from BaseMailBox.move when MOVE command is supported
5+
* Added: chunks argument for BaseMailBox.(copy,move,flag,delete) methods - Number of UIDs to proc at once, to avoid server errors on large set
6+
* Changed: BaseMailBox.(copy,move,flag,delete) result types
7+
* Changed: utils.clean_uids now returns List[str]
8+
* Changed: utils.chunks_crop -> utils.chunked_crop, n arg renamed to chunk_size and it takes False-like vals
9+
* Renamed: utils.chunks -> utils.chunked
10+
111
1.9.1
212
=====
313
* Replaced: functools.lru_cache to functools.cached_property

imap_tools/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@
1111
from .utils import EmailAddress
1212
from .errors import *
1313

14-
__version__ = '1.9.1'
14+
__version__ = '1.10.0'

imap_tools/consts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
PYTHON_VERSION_MINOR = int(sys.version_info.minor)
1111

12+
MOVE_RESULT_TAG = ('_MOVE',) # const delete_result part for mailbox.move result, when server have MOVE in capabilities
1213

1314
class MailMessageFlags:
1415
"""

imap_tools/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ class MailboxCopyError(UnexpectedCommandStatusError):
8585
pass
8686

8787

88+
class MailboxMoveError(UnexpectedCommandStatusError):
89+
pass
90+
91+
8892
class MailboxFlagError(UnexpectedCommandStatusError):
8993
pass
9094

imap_tools/mailbox.py

Lines changed: 86 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
from .message import MailMessage
88
from .folder import MailBoxFolderManager
99
from .idle import IdleManager
10-
from .consts import UID_PATTERN, PYTHON_VERSION_MINOR
11-
from .utils import clean_uids, check_command_status, chunks, encode_folder, clean_flags, check_timeout_arg_support, \
12-
chunks_crop, StrOrBytes
10+
from .consts import UID_PATTERN, PYTHON_VERSION_MINOR, MOVE_RESULT_TAG
11+
from .utils import clean_uids, check_command_status, chunked, encode_folder, clean_flags, check_timeout_arg_support, \
12+
chunked_crop, StrOrBytes
1313
from .errors import MailboxStarttlsError, MailboxLoginError, MailboxLogoutError, MailboxNumbersError, \
1414
MailboxFetchError, MailboxExpungeError, MailboxDeleteError, MailboxCopyError, MailboxFlagError, \
15-
MailboxAppendError, MailboxUidsError, MailboxTaggedResponseError
15+
MailboxAppendError, MailboxUidsError, MailboxTaggedResponseError, MailboxMoveError
1616

1717
# Maximal line length when calling readline(). This is to prevent reading arbitrary length lines.
1818
# 20Mb is enough for search response with about 2 000 000 message numbers
@@ -153,7 +153,7 @@ def _fetch_in_bulk(self, uid_list: Sequence[str], message_parts: str, reverse: b
153153
return
154154

155155
if isinstance(bulk, int) and bulk >= 2:
156-
uid_list_seq = chunks_crop(uid_list, bulk)
156+
uid_list_seq = chunked_crop(uid_list, bulk)
157157
elif isinstance(bulk, bool):
158158
uid_list_seq = (uid_list,)
159159
else:
@@ -164,7 +164,7 @@ def _fetch_in_bulk(self, uid_list: Sequence[str], message_parts: str, reverse: b
164164
check_command_status(fetch_result, MailboxFetchError)
165165
if not fetch_result[1] or fetch_result[1][0] is None:
166166
return
167-
for built_fetch_item in chunks((reversed if reverse else iter)(fetch_result[1]), 2):
167+
for built_fetch_item in chunked((reversed if reverse else iter)(fetch_result[1]), 2):
168168
yield built_fetch_item
169169

170170
def fetch(self, criteria: Criteria = 'ALL', charset: str = 'US-ASCII', limit: Optional[Union[int, slice]] = None,
@@ -204,62 +204,105 @@ def expunge(self) -> tuple:
204204
check_command_status(result, MailboxExpungeError)
205205
return result
206206

207-
def delete(self, uid_list: Union[str, Iterable[str]]) -> Optional[Tuple[tuple, tuple]]:
207+
def delete(self, uid_list: Union[str, Iterable[str]], chunks: Optional[int] = None) \
208+
-> Optional[List[Tuple[tuple, tuple]]]:
208209
"""
209210
Delete email messages
210211
Do nothing on empty uid_list
212+
:param uid_list: UIDs for delete
213+
:param chunks: Number of UIDs to process at once, to avoid server errors on large set. Proc all at once if None.
211214
:return: None on empty uid_list, command results otherwise
212215
"""
213-
uid_str = clean_uids(uid_list)
214-
if not uid_str:
216+
cleaned_uid_list = clean_uids(uid_list)
217+
if not cleaned_uid_list:
215218
return None
216-
store_result = self.client.uid('STORE', uid_str, '+FLAGS', r'(\Deleted)')
217-
check_command_status(store_result, MailboxDeleteError)
218-
expunge_result = self.expunge()
219-
return store_result, expunge_result
220-
221-
def copy(self, uid_list: Union[str, Iterable[str]], destination_folder: StrOrBytes) -> Optional[tuple]:
219+
results = []
220+
for cleaned_uid_list_i in chunked_crop(cleaned_uid_list, chunks):
221+
store_result = self.client.uid('STORE', ','.join(cleaned_uid_list_i), '+FLAGS', r'(\Deleted)')
222+
check_command_status(store_result, MailboxDeleteError)
223+
expunge_result = self.expunge()
224+
results.append((store_result, expunge_result))
225+
return results
226+
227+
def copy(self, uid_list: Union[str, Iterable[str]], destination_folder: StrOrBytes, chunks: Optional[int] = None) \
228+
-> Optional[List[tuple]]:
222229
"""
223-
Copy email messages into the specified folder
224-
Do nothing on empty uid_list
230+
Copy email messages into the specified folder.
231+
Do nothing on empty uid_list.
232+
:param uid_list: UIDs for copy
233+
:param destination_folder: Folder for email copies
234+
:param chunks: Number of UIDs to process at once, to avoid server errors on large set. Proc all at once if None.
225235
:return: None on empty uid_list, command results otherwise
226236
"""
227-
uid_str = clean_uids(uid_list)
228-
if not uid_str:
237+
cleaned_uid_list = clean_uids(uid_list)
238+
if not cleaned_uid_list:
229239
return None
230-
copy_result = self.client.uid('COPY', uid_str, encode_folder(destination_folder)) # noqa
231-
check_command_status(copy_result, MailboxCopyError)
232-
return copy_result
233-
234-
def move(self, uid_list: Union[str, Iterable[str]], destination_folder: StrOrBytes) -> Optional[Tuple[tuple, tuple]]:
240+
results = []
241+
for cleaned_uid_list_i in chunked_crop(cleaned_uid_list, chunks):
242+
copy_result = self.client.uid(
243+
'COPY', ','.join(cleaned_uid_list_i), encode_folder(destination_folder)) # noqa
244+
check_command_status(copy_result, MailboxCopyError)
245+
results.append(copy_result)
246+
return results
247+
248+
def move(self, uid_list: Union[str, Iterable[str]], destination_folder: StrOrBytes, chunks: Optional[int] = None) \
249+
-> Optional[List[Tuple[tuple, tuple]]]:
235250
"""
236-
Move email messages into the specified folder
237-
Do nothing on empty uid_list
251+
Move email messages into the specified folder.
252+
Do nothing on empty uid_list.
253+
:param uid_list: UIDs for move
254+
:param destination_folder: Folder for move to
255+
:param chunks: Number of UIDs to process at once, to avoid server errors on large set. Proc all at once if None.
238256
:return: None on empty uid_list, command results otherwise
239257
"""
240-
uid_str = clean_uids(uid_list)
241-
if not uid_str:
258+
cleaned_uid_list = clean_uids(uid_list)
259+
if not cleaned_uid_list:
242260
return None
243-
copy_result = self.copy(uid_str, destination_folder)
244-
delete_result = self.delete(uid_str)
245-
return copy_result, delete_result
246-
247-
def flag(self, uid_list: Union[str, Iterable[str]], flag_set: Union[str, Iterable[str]], value: bool) \
248-
-> Optional[Tuple[tuple, tuple]]:
261+
if 'MOVE' in self.client.capabilities:
262+
# server side move
263+
results = []
264+
for cleaned_uid_list_i in chunked_crop(cleaned_uid_list, chunks):
265+
move_result = self.client.uid(
266+
'MOVE', ','.join(cleaned_uid_list_i), encode_folder(destination_folder)) # noqa
267+
check_command_status(move_result, MailboxMoveError)
268+
results.append((move_result, MOVE_RESULT_TAG))
269+
return results
270+
else:
271+
# client side move
272+
results = []
273+
for cleaned_uid_list_i in chunked_crop(cleaned_uid_list, chunks):
274+
copy_result = self.copy(cleaned_uid_list_i, destination_folder)
275+
delete_result = self.delete(cleaned_uid_list_i)
276+
results.append((copy_result, delete_result))
277+
return results
278+
279+
def flag(self, uid_list: Union[str, Iterable[str]], flag_set: Union[str, Iterable[str]], value: bool,
280+
chunks: Optional[int] = None) -> Optional[List[Tuple[tuple, tuple]]]:
249281
"""
250-
Set/unset email flags
251-
Do nothing on empty uid_list
282+
Set/unset email flags.
283+
Do nothing on empty uid_list.
252284
System flags contains in consts.MailMessageFlags.all
285+
:param uid_list: UIDs for set flag
286+
:param flag_set: Flags for operate
287+
:param value: Should the flags be set: True - yes, False - no
288+
:param chunks: Number of UIDs to process at once, to avoid server errors on large set. Proc all at once if None.
253289
:return: None on empty uid_list, command results otherwise
254290
"""
255-
uid_str = clean_uids(uid_list)
256-
if not uid_str:
291+
cleaned_uid_list = clean_uids(uid_list)
292+
if not cleaned_uid_list:
257293
return None
258-
store_result = self.client.uid(
259-
'STORE', uid_str, ('+' if value else '-') + 'FLAGS', f'({" ".join(clean_flags(flag_set))})')
260-
check_command_status(store_result, MailboxFlagError)
261-
expunge_result = self.expunge()
262-
return store_result, expunge_result
294+
results = []
295+
for cleaned_uid_list_i in chunked_crop(cleaned_uid_list, chunks):
296+
store_result = self.client.uid(
297+
'STORE',
298+
','.join(cleaned_uid_list_i),
299+
('+' if value else '-') + 'FLAGS',
300+
f'({" ".join(clean_flags(flag_set))})'
301+
)
302+
check_command_status(store_result, MailboxFlagError)
303+
expunge_result = self.expunge()
304+
results.append((store_result, expunge_result))
305+
return results
263306

264307
def append(self, message: Union[MailMessage, bytes],
265308
folder: StrOrBytes = 'INBOX',

imap_tools/query.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ def cleaned_uid(key: str, value: Union[str, Iterable[str], UidRange]) -> str:
209209
return str(value)
210210
# set
211211
try:
212-
return clean_uids(value)
212+
return ','.join(clean_uids(value))
213213
except TypeError as e:
214214
raise TypeError(f'{key} parse error: {str(e)}')
215215

imap_tools/utils.py

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,35 @@
44
from itertools import zip_longest
55
from email.utils import getaddresses, parsedate_to_datetime
66
from email.header import decode_header, Header
7-
from typing import Union, Optional, Tuple, Iterable, Any, List, Dict, Iterator
7+
from typing import Union, Optional, Tuple, Iterable, Any, List, Dict, Iterator, Sequence
88

99
from .consts import SHORT_MONTH_NAMES, MailMessageFlags
1010
from .imap_utf7 import utf7_encode
1111

1212
StrOrBytes = Union[str, bytes]
1313

1414

15-
def clean_uids(uid_set: Union[str, Iterable[str]]) -> str:
15+
def clean_uids(uid_set: Union[str, Iterable[str]]) -> List[str]:
1616
"""
1717
Prepare set of uid for use in IMAP commands
1818
uid RE patterns are not strict and allow invalid combinations, but simple. Example: 2,4:7,9,12:*
1919
:param uid_set:
2020
str, that is comma separated uids
2121
Iterable, that contains str uids
22-
:return: str - uids, concatenated by a comma
22+
:return: list of str - cleaned uids
2323
"""
2424
# str
2525
if type(uid_set) is str:
2626
if re.search(r'^([\d*:]+,)*[\d*:]+$', uid_set): # *optimization for already good str
27-
return uid_set
27+
return uid_set.split(',')
2828
uid_set = uid_set.split(',')
2929
# check uid types
3030
for uid in uid_set:
3131
if type(uid) is not str:
3232
raise TypeError(f'uid "{str(uid)}" is not string')
3333
if not re.match(r'^[\d*:]+$', uid.strip()):
3434
raise TypeError(f'Wrong uid: "{uid}"')
35-
return ','.join(i.strip() for i in uid_set)
35+
return [i.strip() for i in uid_set]
3636

3737

3838
def check_command_status(command_result: tuple, exception: type, expected='OK'):
@@ -205,23 +205,27 @@ def replace_html_ct_charset(html: str, new_charset: str) -> str:
205205
return html
206206

207207

208-
def chunks(iterable: Iterable[Any], n: int, fill_value: Optional[Any] = None) -> Iterator[Tuple[Any, ...]]:
208+
def chunked(iterable: Iterable[Any], n: int, fill_value: Optional[Any] = None) -> Iterator[Tuple[Any, ...]]:
209209
"""
210210
Group data into fixed-length chunks or blocks
211211
[iter(iterable)]*n creates one iterator, repeated n times in the list
212212
izip_longest then effectively performs a round-robin of "each" (same) iterator
213213
Examples:
214-
chunks('ABCDEFGH', 3, '?') --> [('A', 'B', 'C'), ('D', 'E', 'F'), ('G', 'H', '?')]
215-
chunks([1, 2, 3, 4, 5], 2) --> [(1, 2), (3, 4), (5, None)]
214+
chunked('ABCDEFGH', 3, '?') --> [('A', 'B', 'C'), ('D', 'E', 'F'), ('G', 'H', '?')]
215+
chunked([1, 2, 3, 4, 5], 2) --> [(1, 2), (3, 4), (5, None)]
216216
"""
217217
return zip_longest(*[iter(iterable)] * n, fillvalue=fill_value)
218218

219219

220-
def chunks_crop(lst: iter, n: int) -> iter:
220+
def chunked_crop(seq: Sequence, chunk_size: Optional[int]) -> Iterator[list]:
221221
"""
222-
Yield successive n-sized chunks from lst.
222+
Yield successive n-sized chunks from seq.
223+
Yield seq if chunk_size is False-like
224+
:param seq: Sequence to chunks
225+
:param chunk_size: chunk size
226+
:return: Iterator
223227
import pprint
224-
pprint.pprint(list(chunks(range(10, 75), 10)))
228+
pprint.pprint(list(chunked_crop(range(10, 75), 10)))
225229
[[10, 11, 12, 13, 14, 15, 16, 17, 18, 19],
226230
[20, 21, 22, 23, 24, 25, 26, 27, 28, 29],
227231
[30, 31, 32, 33, 34, 35, 36, 37, 38, 39],
@@ -230,5 +234,10 @@ def chunks_crop(lst: iter, n: int) -> iter:
230234
[60, 61, 62, 63, 64, 65, 66, 67, 68, 69],
231235
[70, 71, 72, 73, 74]]
232236
"""
233-
for i in range(0, len(lst), n):
234-
yield lst[i:i + n]
237+
if not chunk_size:
238+
yield seq
239+
return
240+
if chunk_size < 0:
241+
raise ValueError('False-like or int>=0 expected')
242+
for i in range(0, len(seq), chunk_size):
243+
yield seq[i:i + chunk_size]

0 commit comments

Comments
 (0)