IMAPClient 4.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- imapclient/__init__.py +12 -0
- imapclient/config.py +219 -0
- imapclient/datetime_util.py +70 -0
- imapclient/exceptions.py +43 -0
- imapclient/fixed_offset.py +45 -0
- imapclient/imap4.py +18 -0
- imapclient/imap_utf7.py +108 -0
- imapclient/imapclient.py +2080 -0
- imapclient/interact.py +155 -0
- imapclient/livetest.py +1207 -0
- imapclient/response_lexer.py +174 -0
- imapclient/response_parser.py +287 -0
- imapclient/response_types.py +154 -0
- imapclient/testable_imapclient.py +41 -0
- imapclient/tls.py +45 -0
- imapclient/typing_imapclient.py +4 -0
- imapclient/util.py +63 -0
- imapclient/version.py +17 -0
- imapclient-4.0.0.dist-info/METADATA +196 -0
- imapclient-4.0.0.dist-info/RECORD +25 -0
- imapclient-4.0.0.dist-info/WHEEL +5 -0
- imapclient-4.0.0.dist-info/entry_points.txt +3 -0
- imapclient-4.0.0.dist-info/licenses/AUTHORS.rst +51 -0
- imapclient-4.0.0.dist-info/licenses/COPYING +24 -0
- imapclient-4.0.0.dist-info/top_level.txt +1 -0
imapclient/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Copyright (c) 2014, Menno Smits
|
|
2
|
+
# Released subject to the New BSD License
|
|
3
|
+
# Please see http://en.wikipedia.org/wiki/BSD_licenses
|
|
4
|
+
|
|
5
|
+
# version_info provides the version number in programmer friendly way.
|
|
6
|
+
# The 4th part will be either alpha, beta or final.
|
|
7
|
+
|
|
8
|
+
from .imapclient import * # noqa: F401,F403
|
|
9
|
+
from .response_parser import * # noqa: F401,F403
|
|
10
|
+
from .tls import * # noqa: F401,F403
|
|
11
|
+
from .version import version as __version__ # noqa: F401
|
|
12
|
+
from .version import version_info # noqa: F401
|
imapclient/config.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# Copyright (c) 2015, Menno Smits
|
|
2
|
+
# Released subject to the New BSD License
|
|
3
|
+
# Please see http://en.wikipedia.org/wiki/BSD_licenses
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import configparser
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import ssl
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
from typing import Any, Callable, Dict, Optional, Tuple, TYPE_CHECKING, TypeVar
|
|
13
|
+
|
|
14
|
+
import imapclient
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def getenv(name: str, default: Optional[str]) -> Optional[str]:
|
|
18
|
+
return os.environ.get("imapclient_" + name, default)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_config_defaults() -> Dict[str, Any]:
|
|
22
|
+
return {
|
|
23
|
+
"username": getenv("username", None),
|
|
24
|
+
"password": getenv("password", None),
|
|
25
|
+
"ssl": True,
|
|
26
|
+
"ssl_check_hostname": True,
|
|
27
|
+
"ssl_verify_cert": True,
|
|
28
|
+
"ssl_ca_file": None,
|
|
29
|
+
"timeout": None,
|
|
30
|
+
"starttls": False,
|
|
31
|
+
"stream": False,
|
|
32
|
+
"oauth2": False,
|
|
33
|
+
"oauth2_client_id": getenv("oauth2_client_id", None),
|
|
34
|
+
"oauth2_client_secret": getenv("oauth2_client_secret", None),
|
|
35
|
+
"oauth2_refresh_token": getenv("oauth2_refresh_token", None),
|
|
36
|
+
"expect_failure": None,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_config_file(filename: str) -> argparse.Namespace:
|
|
41
|
+
"""Parse INI files containing IMAP connection details.
|
|
42
|
+
|
|
43
|
+
Used by imapclient.livetest and imapclient.interact
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
parser = configparser.ConfigParser(get_string_config_defaults())
|
|
47
|
+
parser.read(filename)
|
|
48
|
+
|
|
49
|
+
conf = _read_config_section(parser, "DEFAULT")
|
|
50
|
+
if conf.expect_failure:
|
|
51
|
+
raise ValueError("expect_failure should not be set for the DEFAULT section")
|
|
52
|
+
|
|
53
|
+
conf.alternates = {}
|
|
54
|
+
for section in parser.sections():
|
|
55
|
+
# pylint: disable=no-member
|
|
56
|
+
conf.alternates[section] = _read_config_section(parser, section)
|
|
57
|
+
|
|
58
|
+
return conf
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_string_config_defaults() -> Dict[str, str]:
|
|
62
|
+
out = {}
|
|
63
|
+
for k, v in get_config_defaults().items():
|
|
64
|
+
if v is True:
|
|
65
|
+
v = "true"
|
|
66
|
+
elif v is False:
|
|
67
|
+
v = "false"
|
|
68
|
+
elif not v:
|
|
69
|
+
v = ""
|
|
70
|
+
out[k] = v
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
T = TypeVar("T")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read_config_section(
|
|
78
|
+
parser: configparser.ConfigParser, section: str
|
|
79
|
+
) -> argparse.Namespace:
|
|
80
|
+
def get(name: str) -> str:
|
|
81
|
+
return parser.get(section, name)
|
|
82
|
+
|
|
83
|
+
def getboolean(name: str) -> bool:
|
|
84
|
+
return parser.getboolean(section, name)
|
|
85
|
+
|
|
86
|
+
def get_allowing_none(name: str, typefunc: Callable[[str], T]) -> Optional[T]:
|
|
87
|
+
try:
|
|
88
|
+
v = parser.get(section, name)
|
|
89
|
+
except configparser.NoOptionError:
|
|
90
|
+
return None
|
|
91
|
+
if not v:
|
|
92
|
+
return None
|
|
93
|
+
return typefunc(v)
|
|
94
|
+
|
|
95
|
+
def getint(name: str) -> Optional[int]:
|
|
96
|
+
return get_allowing_none(name, int)
|
|
97
|
+
|
|
98
|
+
def getfloat(name: str) -> Optional[float]:
|
|
99
|
+
return get_allowing_none(name, float)
|
|
100
|
+
|
|
101
|
+
ssl_ca_file = get("ssl_ca_file")
|
|
102
|
+
if ssl_ca_file:
|
|
103
|
+
ssl_ca_file = os.path.expanduser(ssl_ca_file)
|
|
104
|
+
|
|
105
|
+
return argparse.Namespace(
|
|
106
|
+
host=get("host"),
|
|
107
|
+
port=getint("port"),
|
|
108
|
+
ssl=getboolean("ssl"),
|
|
109
|
+
starttls=getboolean("starttls"),
|
|
110
|
+
ssl_check_hostname=getboolean("ssl_check_hostname"),
|
|
111
|
+
ssl_verify_cert=getboolean("ssl_verify_cert"),
|
|
112
|
+
ssl_ca_file=ssl_ca_file,
|
|
113
|
+
timeout=getfloat("timeout"),
|
|
114
|
+
stream=getboolean("stream"),
|
|
115
|
+
username=get("username"),
|
|
116
|
+
password=get("password"),
|
|
117
|
+
oauth2=getboolean("oauth2"),
|
|
118
|
+
oauth2_client_id=get("oauth2_client_id"),
|
|
119
|
+
oauth2_client_secret=get("oauth2_client_secret"),
|
|
120
|
+
oauth2_refresh_token=get("oauth2_refresh_token"),
|
|
121
|
+
expect_failure=get("expect_failure"),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
OAUTH2_REFRESH_URLS = {
|
|
126
|
+
"imap.gmail.com": "https://accounts.google.com/o/oauth2/token",
|
|
127
|
+
"imap.mail.yahoo.com": "https://api.login.yahoo.com/oauth2/get_token",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def refresh_oauth2_token(
|
|
132
|
+
hostname: str, client_id: str, client_secret: str, refresh_token: str
|
|
133
|
+
) -> str:
|
|
134
|
+
url = OAUTH2_REFRESH_URLS.get(hostname)
|
|
135
|
+
if not url:
|
|
136
|
+
raise ValueError("don't know where to refresh OAUTH2 token for %r" % hostname)
|
|
137
|
+
|
|
138
|
+
post = {
|
|
139
|
+
"client_id": client_id.encode("ascii"),
|
|
140
|
+
"client_secret": client_secret.encode("ascii"),
|
|
141
|
+
"refresh_token": refresh_token.encode("ascii"),
|
|
142
|
+
"grant_type": b"refresh_token",
|
|
143
|
+
}
|
|
144
|
+
with urllib.request.urlopen(
|
|
145
|
+
url, urllib.parse.urlencode(post).encode("ascii")
|
|
146
|
+
) as request:
|
|
147
|
+
response = request.read()
|
|
148
|
+
result = json.loads(response.decode("ascii"))["access_token"]
|
|
149
|
+
if TYPE_CHECKING:
|
|
150
|
+
assert isinstance(result, str)
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# Tokens are expensive to refresh so use the same one for the duration of the process.
|
|
155
|
+
_oauth2_cache: Dict[Tuple[str, str, str, str], str] = {}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def get_oauth2_token(
|
|
159
|
+
hostname: str, client_id: str, client_secret: str, refresh_token: str
|
|
160
|
+
) -> str:
|
|
161
|
+
cache_key = (hostname, client_id, client_secret, refresh_token)
|
|
162
|
+
token = _oauth2_cache.get(cache_key)
|
|
163
|
+
if token:
|
|
164
|
+
return token
|
|
165
|
+
|
|
166
|
+
token = refresh_oauth2_token(hostname, client_id, client_secret, refresh_token)
|
|
167
|
+
_oauth2_cache[cache_key] = token
|
|
168
|
+
return token
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def create_client_from_config(
|
|
172
|
+
conf: argparse.Namespace, login: bool = True
|
|
173
|
+
) -> imapclient.IMAPClient:
|
|
174
|
+
assert conf.host, "missing host"
|
|
175
|
+
|
|
176
|
+
ssl_context = None
|
|
177
|
+
if conf.ssl:
|
|
178
|
+
ssl_context = ssl.create_default_context()
|
|
179
|
+
ssl_context.check_hostname = conf.ssl_check_hostname
|
|
180
|
+
if not conf.ssl_verify_cert:
|
|
181
|
+
ssl_context.verify_mode = ssl.CERT_NONE
|
|
182
|
+
if conf.ssl_ca_file:
|
|
183
|
+
ssl_context.load_verify_locations(cafile=conf.ssl_ca_file)
|
|
184
|
+
|
|
185
|
+
client = imapclient.IMAPClient(
|
|
186
|
+
conf.host,
|
|
187
|
+
port=conf.port,
|
|
188
|
+
ssl=conf.ssl,
|
|
189
|
+
ssl_context=ssl_context,
|
|
190
|
+
stream=conf.stream,
|
|
191
|
+
timeout=conf.timeout,
|
|
192
|
+
)
|
|
193
|
+
if not login:
|
|
194
|
+
return client
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
if conf.starttls:
|
|
198
|
+
client.starttls()
|
|
199
|
+
|
|
200
|
+
if conf.oauth2:
|
|
201
|
+
assert conf.oauth2_client_id, "missing oauth2 id"
|
|
202
|
+
assert conf.oauth2_client_secret, "missing oauth2 secret"
|
|
203
|
+
assert conf.oauth2_refresh_token, "missing oauth2 refresh token"
|
|
204
|
+
access_token = get_oauth2_token(
|
|
205
|
+
conf.host,
|
|
206
|
+
conf.oauth2_client_id,
|
|
207
|
+
conf.oauth2_client_secret,
|
|
208
|
+
conf.oauth2_refresh_token,
|
|
209
|
+
)
|
|
210
|
+
client.oauth2_login(conf.username, access_token)
|
|
211
|
+
|
|
212
|
+
elif not conf.stream:
|
|
213
|
+
assert conf.username, "missing username"
|
|
214
|
+
assert conf.password, "missing password"
|
|
215
|
+
client.login(conf.username, conf.password)
|
|
216
|
+
return client
|
|
217
|
+
except: # noqa: E722
|
|
218
|
+
client.shutdown()
|
|
219
|
+
raise
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Copyright (c) 2014, Menno Smits
|
|
2
|
+
# Released subject to the New BSD License
|
|
3
|
+
# Please see http://en.wikipedia.org/wiki/BSD_licenses
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from email.utils import parsedate_tz
|
|
8
|
+
|
|
9
|
+
from .fixed_offset import FixedOffset
|
|
10
|
+
|
|
11
|
+
_SHORT_MONTHS = " Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" ")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_to_datetime(timestamp: bytes, normalise: bool = True) -> datetime:
|
|
15
|
+
"""Convert an IMAP datetime string to a datetime.
|
|
16
|
+
|
|
17
|
+
If normalise is True (the default), then the returned datetime
|
|
18
|
+
will be timezone-naive but adjusted to the local time.
|
|
19
|
+
|
|
20
|
+
If normalise is False, then the returned datetime will be
|
|
21
|
+
unadjusted but will contain timezone information as per the input.
|
|
22
|
+
"""
|
|
23
|
+
time_tuple = parsedate_tz(_munge(timestamp))
|
|
24
|
+
if time_tuple is None:
|
|
25
|
+
raise ValueError("couldn't parse datetime %r" % timestamp)
|
|
26
|
+
|
|
27
|
+
tz_offset_seconds = time_tuple[-1]
|
|
28
|
+
tz = None
|
|
29
|
+
if tz_offset_seconds is not None:
|
|
30
|
+
tz = FixedOffset(tz_offset_seconds / 60)
|
|
31
|
+
|
|
32
|
+
dt = datetime(*time_tuple[:6], tzinfo=tz)
|
|
33
|
+
if normalise and tz:
|
|
34
|
+
dt = datetime_to_native(dt)
|
|
35
|
+
|
|
36
|
+
return dt
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def datetime_to_native(dt: datetime) -> datetime:
|
|
40
|
+
return dt.astimezone(FixedOffset.for_system()).replace(tzinfo=None)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def datetime_to_INTERNALDATE(dt: datetime) -> str:
|
|
44
|
+
"""Convert a datetime instance to a IMAP INTERNALDATE string.
|
|
45
|
+
|
|
46
|
+
If timezone information is missing the current system
|
|
47
|
+
timezone is used.
|
|
48
|
+
"""
|
|
49
|
+
if not dt.tzinfo:
|
|
50
|
+
dt = dt.replace(tzinfo=FixedOffset.for_system())
|
|
51
|
+
fmt = "%d-" + _SHORT_MONTHS[dt.month] + "-%Y %H:%M:%S %z"
|
|
52
|
+
return dt.strftime(fmt)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Matches timestamp strings where the time separator is a dot (see
|
|
56
|
+
# issue #154). For example: 'Sat, 8 May 2010 16.03.09 +0200'
|
|
57
|
+
_rfc822_dotted_time = re.compile(r"\w+, ?\d{1,2} \w+ \d\d(\d\d)? \d\d?\.\d\d?\.\d\d?.*")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _munge(timestamp: bytes) -> str:
|
|
61
|
+
s = timestamp.decode("latin-1") # parsedate_tz only works with strings
|
|
62
|
+
if _rfc822_dotted_time.match(s):
|
|
63
|
+
return s.replace(".", ":")
|
|
64
|
+
return s
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def format_criteria_date(dt: datetime) -> bytes:
|
|
68
|
+
"""Format a date or datetime instance for use in IMAP search criteria."""
|
|
69
|
+
out = "%02d-%s-%d" % (dt.day, _SHORT_MONTHS[dt.month], dt.year)
|
|
70
|
+
return out.encode("ascii")
|
imapclient/exceptions.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import imaplib
|
|
2
|
+
|
|
3
|
+
# Base class allowing to catch any IMAPClient related exceptions
|
|
4
|
+
# To ensure backward compatibility, we "rename" the imaplib general
|
|
5
|
+
# exception class, so we can catch its exceptions without having to
|
|
6
|
+
# deal with it in IMAPClient codebase
|
|
7
|
+
|
|
8
|
+
IMAPClientError = imaplib.IMAP4.error
|
|
9
|
+
IMAPClientAbortError = imaplib.IMAP4.abort
|
|
10
|
+
IMAPClientReadOnlyError = imaplib.IMAP4.readonly
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CapabilityError(IMAPClientError):
|
|
14
|
+
"""
|
|
15
|
+
The command tried by the user needs a capability not installed
|
|
16
|
+
on the IMAP server
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LoginError(IMAPClientError):
|
|
21
|
+
"""
|
|
22
|
+
A connection has been established with the server but an error
|
|
23
|
+
occurred during the authentication.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class IllegalStateError(IMAPClientError):
|
|
28
|
+
"""
|
|
29
|
+
The command tried needs a different state to be executed. This
|
|
30
|
+
means the user is not logged in or the command needs a folder to
|
|
31
|
+
be selected.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InvalidCriteriaError(IMAPClientError):
|
|
36
|
+
"""
|
|
37
|
+
A command using a search criteria failed, probably due to a syntax
|
|
38
|
+
error in the criteria string.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ProtocolError(IMAPClientError):
|
|
43
|
+
"""The server replied with a response that violates the IMAP protocol."""
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Copyright (c) 2014, Menno Smits
|
|
2
|
+
# Released subject to the New BSD License
|
|
3
|
+
# Please see http://en.wikipedia.org/wiki/BSD_licenses
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import time
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
ZERO = datetime.timedelta(0)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FixedOffset(datetime.tzinfo):
|
|
13
|
+
"""
|
|
14
|
+
This class describes fixed timezone offsets in hours and minutes
|
|
15
|
+
east from UTC
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, minutes: float) -> None:
|
|
19
|
+
self.__offset = datetime.timedelta(minutes=minutes)
|
|
20
|
+
|
|
21
|
+
sign = "+"
|
|
22
|
+
if minutes < 0:
|
|
23
|
+
sign = "-"
|
|
24
|
+
hours, remaining_mins = divmod(abs(minutes), 60)
|
|
25
|
+
self.__name = "%s%02d%02d" % (sign, hours, remaining_mins)
|
|
26
|
+
|
|
27
|
+
def utcoffset(self, _: Optional[datetime.datetime]) -> datetime.timedelta:
|
|
28
|
+
return self.__offset
|
|
29
|
+
|
|
30
|
+
def tzname(self, _: Optional[datetime.datetime]) -> str:
|
|
31
|
+
return self.__name
|
|
32
|
+
|
|
33
|
+
def dst(self, _: Optional[datetime.datetime]) -> datetime.timedelta:
|
|
34
|
+
return ZERO
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def for_system(cls) -> "FixedOffset":
|
|
38
|
+
"""Return a FixedOffset instance for the current working timezone and
|
|
39
|
+
DST conditions.
|
|
40
|
+
"""
|
|
41
|
+
if time.localtime().tm_isdst and time.daylight:
|
|
42
|
+
offset = time.altzone
|
|
43
|
+
else:
|
|
44
|
+
offset = time.timezone
|
|
45
|
+
return cls(-offset // 60)
|
imapclient/imap4.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Copyright (c) 2015, Menno Smits
|
|
2
|
+
# Released subject to the New BSD License
|
|
3
|
+
# Please see http://en.wikipedia.org/wiki/BSD_licenses
|
|
4
|
+
|
|
5
|
+
import imaplib
|
|
6
|
+
import socket
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IMAP4WithTimeout(imaplib.IMAP4):
|
|
11
|
+
def __init__(self, address: str, port: int, timeout: Optional[float]) -> None:
|
|
12
|
+
self._timeout = timeout
|
|
13
|
+
super().__init__(address, port, timeout=timeout)
|
|
14
|
+
|
|
15
|
+
def _create_socket(self, timeout: Optional[float] = None) -> socket.socket:
|
|
16
|
+
return socket.create_connection(
|
|
17
|
+
(self.host, self.port), timeout if timeout is not None else self._timeout
|
|
18
|
+
)
|
imapclient/imap_utf7.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# This file contains two main methods used to encode and decode UTF-7
|
|
2
|
+
# string, described in the RFC 3501. There are some variations specific
|
|
3
|
+
# to IMAP4rev1, so the built-in Python UTF-7 codec can't be used instead.
|
|
4
|
+
#
|
|
5
|
+
# The main difference is the shift character (used to switch from ASCII to
|
|
6
|
+
# base64 encoding context), which is & in this modified UTF-7 convention,
|
|
7
|
+
# since + is considered as mainly used in mailbox names.
|
|
8
|
+
# Other variations and examples can be found in the RFC 3501, section 5.1.3.
|
|
9
|
+
|
|
10
|
+
import binascii
|
|
11
|
+
from typing import List, Union
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def encode(s: Union[str, bytes]) -> bytes:
|
|
15
|
+
"""Encode a folder name using IMAP modified UTF-7 encoding.
|
|
16
|
+
|
|
17
|
+
Input is unicode; output is bytes (Python 3) or str (Python 2). If
|
|
18
|
+
non-unicode input is provided, the input is returned unchanged.
|
|
19
|
+
"""
|
|
20
|
+
if not isinstance(s, str):
|
|
21
|
+
return s
|
|
22
|
+
|
|
23
|
+
res = bytearray()
|
|
24
|
+
|
|
25
|
+
b64_buffer: List[str] = []
|
|
26
|
+
|
|
27
|
+
def consume_b64_buffer(buf: List[str]) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Consume the buffer by encoding it into a modified base 64 representation
|
|
30
|
+
and surround it with shift characters & and -
|
|
31
|
+
"""
|
|
32
|
+
if buf:
|
|
33
|
+
res.extend(b"&" + base64_utf7_encode(buf) + b"-")
|
|
34
|
+
del buf[:]
|
|
35
|
+
|
|
36
|
+
for c in s:
|
|
37
|
+
# printable ascii case should not be modified
|
|
38
|
+
o = ord(c)
|
|
39
|
+
if 0x20 <= o <= 0x7E:
|
|
40
|
+
consume_b64_buffer(b64_buffer)
|
|
41
|
+
# Special case: & is used as shift character so we need to escape it in ASCII
|
|
42
|
+
if o == 0x26: # & = 0x26
|
|
43
|
+
res.extend(b"&-")
|
|
44
|
+
else:
|
|
45
|
+
res.append(o)
|
|
46
|
+
|
|
47
|
+
# Bufferize characters that will be encoded in base64 and append them later
|
|
48
|
+
# in the result, when iterating over ASCII character or the end of string
|
|
49
|
+
else:
|
|
50
|
+
b64_buffer.append(c)
|
|
51
|
+
|
|
52
|
+
# Consume the remaining buffer if the string finish with non-ASCII characters
|
|
53
|
+
consume_b64_buffer(b64_buffer)
|
|
54
|
+
|
|
55
|
+
return bytes(res)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
AMPERSAND_ORD = ord("&")
|
|
59
|
+
DASH_ORD = ord("-")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def decode(s: Union[bytes, str]) -> str:
|
|
63
|
+
"""Decode a folder name from IMAP modified UTF-7 encoding to unicode.
|
|
64
|
+
|
|
65
|
+
Input is bytes (Python 3) or str (Python 2); output is always
|
|
66
|
+
unicode. If non-bytes/str input is provided, the input is returned
|
|
67
|
+
unchanged.
|
|
68
|
+
"""
|
|
69
|
+
if not isinstance(s, bytes):
|
|
70
|
+
return s
|
|
71
|
+
|
|
72
|
+
res = []
|
|
73
|
+
# Store base64 substring that will be decoded once stepping on end shift character
|
|
74
|
+
b64_buffer = bytearray()
|
|
75
|
+
for c in s:
|
|
76
|
+
# Shift character without anything in buffer -> starts storing base64 substring
|
|
77
|
+
if c == AMPERSAND_ORD and not b64_buffer:
|
|
78
|
+
b64_buffer.append(c)
|
|
79
|
+
# End shift char. -> append the decoded buffer to the result and reset it
|
|
80
|
+
elif c == DASH_ORD and b64_buffer:
|
|
81
|
+
# Special case &-, representing "&" escaped
|
|
82
|
+
if len(b64_buffer) == 1:
|
|
83
|
+
res.append("&")
|
|
84
|
+
else:
|
|
85
|
+
res.append(base64_utf7_decode(b64_buffer[1:]))
|
|
86
|
+
b64_buffer = bytearray()
|
|
87
|
+
# Still buffering between the shift character and the shift back to ASCII
|
|
88
|
+
elif b64_buffer:
|
|
89
|
+
b64_buffer.append(c)
|
|
90
|
+
# No buffer initialized yet, should be an ASCII printable char
|
|
91
|
+
else:
|
|
92
|
+
res.append(chr(c))
|
|
93
|
+
|
|
94
|
+
# Decode the remaining buffer if any
|
|
95
|
+
if b64_buffer:
|
|
96
|
+
res.append(base64_utf7_decode(b64_buffer[1:]))
|
|
97
|
+
|
|
98
|
+
return "".join(res)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def base64_utf7_encode(buffer: List[str]) -> bytes:
|
|
102
|
+
s = "".join(buffer).encode("utf-16be")
|
|
103
|
+
return binascii.b2a_base64(s).rstrip(b"\n=").replace(b"/", b",")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def base64_utf7_decode(s: bytearray) -> str:
|
|
107
|
+
s_utf7 = b"+" + s.replace(b",", b"/") + b"-"
|
|
108
|
+
return s_utf7.decode("utf-7")
|