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 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")
@@ -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
+ )
@@ -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")