synapse 2.219.0__py311-none-any.whl → 2.220.0__py311-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.
Potentially problematic release.
This version of synapse might be problematic. Click here for more details.
- synapse/data/__init__.py +4 -0
- synapse/data/lark/__init__.py +0 -0
- synapse/data/lark/imap.lark +8 -0
- synapse/exc.py +2 -0
- synapse/lib/json.py +6 -5
- synapse/lib/link.py +49 -50
- synapse/lib/parser.py +3 -5
- synapse/lib/storm.py +1 -0
- synapse/lib/stormlib/imap.py +476 -35
- synapse/lib/stormtypes.py +39 -0
- synapse/lib/version.py +2 -2
- synapse/tests/test_lib_grammar.py +2 -4
- synapse/tests/test_lib_json.py +29 -0
- synapse/tests/test_lib_storm.py +5 -1
- synapse/tests/test_lib_stormlib_imap.py +1307 -230
- synapse/tests/test_lib_stormtypes.py +32 -0
- synapse/utils/stormcov/plugin.py +2 -5
- {synapse-2.219.0.dist-info → synapse-2.220.0.dist-info}/METADATA +1 -2
- {synapse-2.219.0.dist-info → synapse-2.220.0.dist-info}/RECORD +23 -21
- /synapse/{lib → data/lark}/storm.lark +0 -0
- {synapse-2.219.0.dist-info → synapse-2.220.0.dist-info}/WHEEL +0 -0
- {synapse-2.219.0.dist-info → synapse-2.220.0.dist-info}/licenses/LICENSE +0 -0
- {synapse-2.219.0.dist-info → synapse-2.220.0.dist-info}/top_level.txt +0 -0
synapse/lib/stormlib/imap.py
CHANGED
|
@@ -1,22 +1,458 @@
|
|
|
1
|
+
import random
|
|
1
2
|
import asyncio
|
|
3
|
+
import imaplib
|
|
4
|
+
import logging
|
|
2
5
|
|
|
3
|
-
import
|
|
6
|
+
import lark
|
|
7
|
+
import regex
|
|
4
8
|
|
|
5
9
|
import synapse.exc as s_exc
|
|
10
|
+
import synapse.data as s_data
|
|
6
11
|
import synapse.common as s_common
|
|
12
|
+
|
|
7
13
|
import synapse.lib.coro as s_coro
|
|
14
|
+
import synapse.lib.link as s_link
|
|
8
15
|
import synapse.lib.stormtypes as s_stormtypes
|
|
9
16
|
|
|
10
|
-
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
CRLF = b'\r\n'
|
|
20
|
+
CRLFLEN = len(CRLF)
|
|
21
|
+
UNTAGGED = '*'
|
|
22
|
+
|
|
23
|
+
TAGVAL_MIN = 4096
|
|
24
|
+
TAGVAL_MAX = 65535
|
|
25
|
+
|
|
26
|
+
def quote(text, escape=True):
|
|
27
|
+
if text == '""':
|
|
28
|
+
# Don't quote empty string
|
|
29
|
+
return text
|
|
30
|
+
|
|
31
|
+
if ' ' not in text and '"' not in text and '\\' not in text:
|
|
32
|
+
return text
|
|
33
|
+
|
|
34
|
+
text = text.replace('\\', '\\\\')
|
|
35
|
+
text = text.replace('"', '\\"')
|
|
36
|
+
|
|
37
|
+
return f'"{text}"'
|
|
38
|
+
|
|
39
|
+
_grammar = s_data.getLark('imap')
|
|
40
|
+
LarkParser = lark.Lark(_grammar, regex=True, start='input',
|
|
41
|
+
maybe_placeholders=False, propagate_positions=True, parser='lalr')
|
|
42
|
+
|
|
43
|
+
class AstConverter(lark.Transformer):
|
|
44
|
+
def quoted(self, args):
|
|
45
|
+
return ''.join(args)
|
|
46
|
+
|
|
47
|
+
def unquoted(self, args):
|
|
48
|
+
return ''.join(args)
|
|
49
|
+
|
|
50
|
+
def qsplit(text):
|
|
51
|
+
'''
|
|
52
|
+
Split on spaces.
|
|
53
|
+
Preserve quoted strings.
|
|
54
|
+
Unescape backslash and double quotes.
|
|
55
|
+
Unquote quoted strings.
|
|
56
|
+
|
|
57
|
+
Raise BadDataValu if:
|
|
58
|
+
- quotes are unclosed.
|
|
59
|
+
- quoted strings don't have a space before/after (not including beginning/end of line).
|
|
60
|
+
- double-quotes or backslashes are escaped outside of a quoted string.
|
|
61
|
+
'''
|
|
62
|
+
def on_error(exc):
|
|
63
|
+
# Escaped double-quote or backslash not in quotes
|
|
64
|
+
if exc.token_history and len(exc.token_history) == 1 and (tok := exc.token_history[0]).type == 'UNQUOTED_CHAR' and tok.value == '\\':
|
|
65
|
+
mesg = f'Invalid data: {exc.token.value} cannot be escaped.'
|
|
66
|
+
raise s_exc.BadDataValu(mesg=mesg, data=text) from None
|
|
67
|
+
|
|
68
|
+
if exc.token.type == 'UNQUOTED_CHAR' and exc.expected == {'QUOTED_SPECIALS'}:
|
|
69
|
+
mesg = f'Invalid data: {exc.token.value} cannot be escaped.'
|
|
70
|
+
raise s_exc.BadDataValu(mesg=mesg, data=text) from None
|
|
71
|
+
|
|
72
|
+
# Double quote (opening a quoted string) at end of line
|
|
73
|
+
if exc.token.type == 'DBLQUOTE' and exc.column == len(text):
|
|
74
|
+
mesg = 'Quoted strings must be preceded and followed by a space.'
|
|
75
|
+
raise s_exc.BadDataValu(mesg=mesg, data=text) from None
|
|
76
|
+
|
|
77
|
+
# Unclosed quoted string
|
|
78
|
+
if exc.token.type == '$END' and exc.column == len(text) and exc.expected == {'QUOTED_CHAR', 'DBLQUOTE', 'BACKSLASH'}:
|
|
79
|
+
mesg = 'Unclosed quotes in text.'
|
|
80
|
+
raise s_exc.BadDataValu(mesg=mesg, data=text) from None
|
|
81
|
+
|
|
82
|
+
# Catch-all exception
|
|
83
|
+
raise s_exc.BadDataValu(mesg='Unable to parse IMAP response data.', data=text) from None # pragma: no cover
|
|
84
|
+
|
|
85
|
+
tree = LarkParser.parse(text, on_error=on_error)
|
|
86
|
+
newtree = AstConverter(text).transform(tree)
|
|
87
|
+
return newtree.children
|
|
88
|
+
|
|
89
|
+
imap_rgx = regex.compile(
|
|
90
|
+
br'''
|
|
91
|
+
^
|
|
92
|
+
(?P<tag>\*|\+|[0-9a-zA-Z]+) # tag is mandatory
|
|
93
|
+
(\s(?P<uid>[0-9]+))? # uid is optional
|
|
94
|
+
(\s(?P<response>[A-Z]{2,})) # response is mandatory
|
|
95
|
+
(\s\[(?P<code>.*?)\])? # code is optional
|
|
96
|
+
(\s(?P<data>.*?(?! {\d+})))? # data is optional
|
|
97
|
+
(\s({(?P<size>\d+)}))? # size is optional
|
|
98
|
+
$
|
|
99
|
+
''',
|
|
100
|
+
flags=regex.VERBOSE
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
imap_rgx_cont = regex.compile(
|
|
104
|
+
br'''
|
|
105
|
+
^
|
|
106
|
+
((?P<data>.*?(?! {\d+})))? # data is optional
|
|
107
|
+
(\s({(?P<size>\d+)}))? # size is optional
|
|
108
|
+
$
|
|
109
|
+
''',
|
|
110
|
+
flags=regex.VERBOSE
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
class IMAPBase(s_link.Link):
|
|
114
|
+
'''
|
|
115
|
+
Base class for IMAPClient and IMAPServer (in test_lib_stormlib_imap.py).
|
|
116
|
+
'''
|
|
117
|
+
async def __anit__(self, reader, writer, info=None, forceclose=False):
|
|
118
|
+
await s_link.Link.__anit__(self, reader, writer, info=info, forceclose=forceclose)
|
|
119
|
+
|
|
120
|
+
self._rxbuf = b''
|
|
121
|
+
self.state = 'LOGOUT'
|
|
122
|
+
|
|
123
|
+
def _parseLine(self, line): # pragma: no cover
|
|
124
|
+
raise NotImplementedError('Not implemented')
|
|
125
|
+
|
|
126
|
+
def pack(self, mesg): # pragma: no cover
|
|
127
|
+
raise NotImplementedError('Not implemented')
|
|
128
|
+
|
|
129
|
+
def feed(self, byts):
|
|
130
|
+
ret = []
|
|
131
|
+
|
|
132
|
+
# Append new bytes to existing bytes
|
|
133
|
+
self._rxbuf += byts
|
|
134
|
+
|
|
135
|
+
# Iterate through buffer and parse out (up to 32) complete messages.
|
|
136
|
+
# NB: The 32 message maximum is an arbitrary number to keep this loop
|
|
137
|
+
# from running forever with an endless number of messages from the
|
|
138
|
+
# server.
|
|
139
|
+
while (offs := self._rxbuf.find(CRLF)) != -1 and len(ret) < 32:
|
|
140
|
+
|
|
141
|
+
# Get the line out of the buffer
|
|
142
|
+
line = self._rxbuf[:offs]
|
|
143
|
+
|
|
144
|
+
# Parse line
|
|
145
|
+
mesg = self._parseLine(line)
|
|
146
|
+
|
|
147
|
+
end = offs + CRLFLEN
|
|
148
|
+
|
|
149
|
+
# Handle continuations
|
|
150
|
+
while (size := mesg.get('size')) is not None:
|
|
151
|
+
start = end
|
|
152
|
+
end = start + size
|
|
153
|
+
|
|
154
|
+
# Check for complete data
|
|
155
|
+
if len(self._rxbuf) < start + end - start: # pragma: no cover
|
|
156
|
+
return ret
|
|
157
|
+
|
|
158
|
+
# Check for end of message
|
|
159
|
+
if (offs := self._rxbuf[end:].find(CRLF)) == -1: # pragma: no cover
|
|
160
|
+
return ret
|
|
161
|
+
|
|
162
|
+
# Extract the attachment and add it to the message
|
|
163
|
+
attachment = self._rxbuf[start:end]
|
|
164
|
+
mesg['attachments'].append(attachment)
|
|
165
|
+
|
|
166
|
+
msgdata = self._rxbuf[end:end + offs]
|
|
167
|
+
|
|
168
|
+
# Get the data and/or size from the trailing message data
|
|
169
|
+
cont = imap_rgx_cont.match(msgdata).groupdict()
|
|
170
|
+
if (size := cont.get('size')) is not None:
|
|
171
|
+
size = int(size)
|
|
172
|
+
|
|
173
|
+
mesg['size'] = size
|
|
174
|
+
|
|
175
|
+
contdata = cont.get('data', b'')
|
|
176
|
+
mesg['data'] += contdata
|
|
177
|
+
|
|
178
|
+
end = end + offs + CRLFLEN
|
|
179
|
+
|
|
180
|
+
# Increment buffer
|
|
181
|
+
self._rxbuf = self._rxbuf[end:]
|
|
182
|
+
|
|
183
|
+
# Log only under __debug__ because there might be sensitive info like passwords
|
|
184
|
+
if __debug__:
|
|
185
|
+
logger.debug('%s RECV: %s', self.__class__.__name__, mesg)
|
|
186
|
+
|
|
187
|
+
ret.append((None, mesg))
|
|
188
|
+
|
|
189
|
+
return ret
|
|
190
|
+
|
|
191
|
+
class IMAPClient(IMAPBase):
|
|
192
|
+
async def postAnit(self):
|
|
193
|
+
self._tagval = random.randint(TAGVAL_MIN, TAGVAL_MAX)
|
|
194
|
+
self.readonly = False
|
|
195
|
+
self.capabilities = []
|
|
196
|
+
|
|
197
|
+
# Get and handle the server greeting
|
|
198
|
+
response = await self.getResponse()
|
|
199
|
+
greeting = response.get(UNTAGGED)[0]
|
|
200
|
+
|
|
201
|
+
if greeting.get('response') == 'PREAUTH':
|
|
202
|
+
self.state = 'AUTH'
|
|
203
|
+
elif greeting.get('response') == 'OK':
|
|
204
|
+
self.state = 'NONAUTH'
|
|
205
|
+
else:
|
|
206
|
+
# Includes greeting.get('response') == 'BYE'
|
|
207
|
+
raise s_exc.ImapError(mesg=greeting.get('data').decode(), response=response)
|
|
208
|
+
|
|
209
|
+
# Some servers will list capabilities in the greeting
|
|
210
|
+
if (code := greeting.get('code')) is not None and code.startswith('CAPABILITY'):
|
|
211
|
+
self.capabilities = qsplit(code)[1:]
|
|
212
|
+
|
|
213
|
+
if not self.capabilities:
|
|
214
|
+
(ok, data) = await self.capability()
|
|
215
|
+
if not ok:
|
|
216
|
+
mesg = data[0].decode()
|
|
217
|
+
raise s_exc.ImapError(mesg=mesg)
|
|
218
|
+
|
|
219
|
+
return self
|
|
220
|
+
|
|
221
|
+
def _parseLine(self, line):
|
|
222
|
+
match = imap_rgx.match(line)
|
|
223
|
+
if match is None:
|
|
224
|
+
mesg = 'Unable to parse response from server.'
|
|
225
|
+
raise s_exc.ImapError(mesg=mesg, data=line)
|
|
226
|
+
|
|
227
|
+
mesg = match.groupdict()
|
|
228
|
+
|
|
229
|
+
for key, valu in mesg.items():
|
|
230
|
+
if key == 'data' or valu is None:
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
mesg[key] = valu.decode()
|
|
234
|
+
|
|
235
|
+
if mesg.get('data') is None:
|
|
236
|
+
mesg['data'] = b''
|
|
237
|
+
|
|
238
|
+
if (uid := mesg.get('uid')) is not None:
|
|
239
|
+
mesg['uid'] = int(uid)
|
|
240
|
+
|
|
241
|
+
if (size := mesg.get('size')) is not None:
|
|
242
|
+
mesg['size'] = int(size)
|
|
243
|
+
|
|
244
|
+
# For attaching continuation data
|
|
245
|
+
mesg['attachments'] = []
|
|
246
|
+
|
|
247
|
+
return mesg
|
|
248
|
+
|
|
249
|
+
async def pack(self, mesg):
|
|
250
|
+
(tag, command, args) = mesg
|
|
251
|
+
|
|
252
|
+
cmdargs = ''
|
|
253
|
+
if args:
|
|
254
|
+
cmdargs = ' ' + ' '.join(args)
|
|
255
|
+
|
|
256
|
+
mesg = f'{tag} {command}{cmdargs}\r\n'
|
|
257
|
+
|
|
258
|
+
# Log only under __debug__ because there might be sensitive info like passwords
|
|
259
|
+
if __debug__:
|
|
260
|
+
logger.debug('%s SEND: %s', self.__class__.__name__, mesg)
|
|
261
|
+
|
|
262
|
+
return mesg.encode()
|
|
263
|
+
|
|
264
|
+
async def getResponse(self, tag=None):
|
|
265
|
+
resp = {}
|
|
266
|
+
|
|
267
|
+
while True:
|
|
268
|
+
msg = await self.rx()
|
|
269
|
+
|
|
270
|
+
mtag = msg.get('tag')
|
|
271
|
+
resp.setdefault(mtag, []).append(msg)
|
|
272
|
+
|
|
273
|
+
if tag is None or mtag == tag:
|
|
274
|
+
break
|
|
275
|
+
|
|
276
|
+
return resp
|
|
277
|
+
|
|
278
|
+
def _genTag(self):
|
|
279
|
+
self._tagval = (self._tagval + 1) % TAGVAL_MAX
|
|
280
|
+
if self._tagval == 0:
|
|
281
|
+
self._tagval = TAGVAL_MIN
|
|
282
|
+
|
|
283
|
+
return imaplib.Int2AP(self._tagval).decode()
|
|
284
|
+
|
|
285
|
+
async def _command(self, tag, command, *args):
|
|
286
|
+
if command.upper() not in imaplib.Commands:
|
|
287
|
+
mesg = f'Unsupported command: {command}.'
|
|
288
|
+
raise s_exc.ImapError(mesg=mesg, command=command)
|
|
289
|
+
|
|
290
|
+
if self.state not in imaplib.Commands.get(command.upper()):
|
|
291
|
+
mesg = f'{command} not allowed in the {self.state} state.'
|
|
292
|
+
raise s_exc.ImapError(mesg=mesg, state=self.state, command=command)
|
|
293
|
+
|
|
294
|
+
await self.tx((tag, command, args))
|
|
295
|
+
return await self.getResponse(tag)
|
|
296
|
+
|
|
297
|
+
def okSetState(self, response, state):
|
|
298
|
+
if response.get('response') == 'OK':
|
|
299
|
+
self.state = state
|
|
300
|
+
|
|
301
|
+
async def capability(self):
|
|
302
|
+
tag = self._genTag()
|
|
303
|
+
resp = await self._command(tag, 'CAPABILITY')
|
|
304
|
+
|
|
305
|
+
response = resp.get(tag)[0]
|
|
306
|
+
if response.get('response') != 'OK':
|
|
307
|
+
return False, [response.get('data')]
|
|
308
|
+
|
|
309
|
+
if len(untagged := resp.get(UNTAGGED, [])) != 1:
|
|
310
|
+
return False, [b'Invalid server response.']
|
|
311
|
+
|
|
312
|
+
capabilities = untagged[0].get('data').decode()
|
|
313
|
+
self.capabilities = qsplit(capabilities)
|
|
314
|
+
|
|
315
|
+
return True, [capabilities]
|
|
316
|
+
|
|
317
|
+
async def login(self, user, passwd):
|
|
318
|
+
if 'AUTH=PLAIN' not in self.capabilities:
|
|
319
|
+
return False, [b'Plain authentication not available on server.']
|
|
320
|
+
|
|
321
|
+
if 'LOGINDISABLED' in self.capabilities:
|
|
322
|
+
return False, [b'Login disabled on server.']
|
|
323
|
+
|
|
324
|
+
tag = self._genTag()
|
|
325
|
+
resp = await self._command(tag, 'LOGIN', quote(user), quote(passwd))
|
|
326
|
+
|
|
327
|
+
response = resp.get(tag)[0]
|
|
328
|
+
if response.get('response') != 'OK':
|
|
329
|
+
return False, [response.get('data')]
|
|
330
|
+
|
|
331
|
+
# Some servers will update capabilities with the login response
|
|
332
|
+
if (code := response.get('code')) is not None and code.startswith('CAPABILITY'):
|
|
333
|
+
self.capabilities = qsplit(code)[1:]
|
|
334
|
+
|
|
335
|
+
self.okSetState(response, 'AUTH')
|
|
336
|
+
|
|
337
|
+
return True, [response.get('data')]
|
|
338
|
+
|
|
339
|
+
async def select(self, mailbox='INBOX'):
|
|
340
|
+
tag = self._genTag()
|
|
341
|
+
resp = await self._command(tag, 'SELECT', quote(mailbox))
|
|
342
|
+
|
|
343
|
+
response = resp.get(tag)[0]
|
|
344
|
+
if response.get('response') != 'OK':
|
|
345
|
+
return False, [response.get('data')]
|
|
346
|
+
|
|
347
|
+
if (code := response.get('code')) is not None:
|
|
348
|
+
if 'READ-ONLY' in code:
|
|
349
|
+
self.readonly = True
|
|
350
|
+
|
|
351
|
+
if 'READ-WRITE' in code:
|
|
352
|
+
self.readonly = False
|
|
353
|
+
|
|
354
|
+
self.okSetState(response, 'SELECTED')
|
|
355
|
+
return True, [response.get('data')]
|
|
356
|
+
|
|
357
|
+
async def list(self, refname, pattern):
|
|
358
|
+
tag = self._genTag()
|
|
359
|
+
resp = await self._command(tag, 'LIST', quote(refname), quote(pattern))
|
|
360
|
+
|
|
361
|
+
response = resp.get(tag)[0]
|
|
362
|
+
if response.get('response') != 'OK':
|
|
363
|
+
return False, [response.get('data')]
|
|
364
|
+
|
|
365
|
+
data = []
|
|
366
|
+
for mesg in resp.get(UNTAGGED, []):
|
|
367
|
+
data.append(mesg.get('data'))
|
|
368
|
+
|
|
369
|
+
return True, data
|
|
370
|
+
|
|
371
|
+
async def uid_store(self, uidset, dataname, datavalu):
|
|
372
|
+
if self.readonly:
|
|
373
|
+
return False, [b'Selected mailbox is read-only.']
|
|
374
|
+
|
|
375
|
+
args = f'{uidset} {dataname} {datavalu}'
|
|
376
|
+
return await self.uid('STORE', args)
|
|
377
|
+
|
|
378
|
+
async def uid_search(self, *criteria, charset='UTF-8'):
|
|
379
|
+
args = ''
|
|
380
|
+
if charset is not None:
|
|
381
|
+
args += f'CHARSET {charset} '
|
|
382
|
+
args += ' '.join(quote(c) for c in criteria)
|
|
383
|
+
return await self.uid('SEARCH', args)
|
|
384
|
+
|
|
385
|
+
async def uid_fetch(self, uidset, datanames):
|
|
386
|
+
args = f'{uidset} {datanames}'
|
|
387
|
+
return await self.uid('FETCH', args)
|
|
388
|
+
|
|
389
|
+
async def uid(self, cmdname, cmdargs):
|
|
390
|
+
tag = self._genTag()
|
|
391
|
+
|
|
392
|
+
resp = await self._command(tag, 'UID', cmdname, cmdargs)
|
|
393
|
+
|
|
394
|
+
response = resp.get(tag)[0]
|
|
395
|
+
if response.get('response') != 'OK':
|
|
396
|
+
return False, [response.get('data')]
|
|
397
|
+
|
|
398
|
+
untagged = resp.get(UNTAGGED, [])
|
|
399
|
+
|
|
400
|
+
if cmdname == 'FETCH':
|
|
401
|
+
# FETCH returns a list of attachments from each message followed by
|
|
402
|
+
# the message data. For example, a FETCH 4 (RFC822 BODY[HEADER])
|
|
403
|
+
# would return:
|
|
404
|
+
# [ <RFC822 message>, <BODY[HEADER] message>, '(UID 4 RFC822 BODY[HEADER])' ]
|
|
405
|
+
#
|
|
406
|
+
# This allows the consumer to get each of the requested data
|
|
407
|
+
# messages and then parse the message data to figure out which
|
|
408
|
+
# attachment is which.
|
|
409
|
+
|
|
410
|
+
ret = []
|
|
411
|
+
for u in untagged:
|
|
412
|
+
ret.extend(u.get('attachments'))
|
|
413
|
+
ret.append(u.get('data'))
|
|
414
|
+
return True, ret
|
|
415
|
+
|
|
416
|
+
return True, [u.get('data') for u in untagged]
|
|
417
|
+
|
|
418
|
+
async def expunge(self):
|
|
419
|
+
if self.readonly:
|
|
420
|
+
return False, [b'Selected mailbox is read-only.']
|
|
421
|
+
|
|
422
|
+
tag = self._genTag()
|
|
423
|
+
resp = await self._command(tag, 'EXPUNGE')
|
|
424
|
+
|
|
425
|
+
response = resp.get(tag)[0]
|
|
426
|
+
if response.get('response') != 'OK':
|
|
427
|
+
return False, [response.get('data')]
|
|
428
|
+
|
|
429
|
+
return True, [response.get('data')]
|
|
430
|
+
|
|
431
|
+
async def logout(self):
|
|
432
|
+
tag = self._genTag()
|
|
433
|
+
resp = await self._command(tag, 'LOGOUT')
|
|
434
|
+
|
|
435
|
+
response = resp.get(tag)[0]
|
|
436
|
+
if response.get('response') != 'OK':
|
|
437
|
+
return False, [response.get('data')]
|
|
438
|
+
|
|
439
|
+
untagged = resp.get(UNTAGGED, [])
|
|
440
|
+
if len(untagged) != 1 or untagged[0].get('response') != 'BYE':
|
|
441
|
+
return False, [b'Server failed to send expected BYE response.']
|
|
442
|
+
|
|
443
|
+
self.okSetState(response, 'LOGOUT')
|
|
444
|
+
return True, [response.get('data')]
|
|
445
|
+
|
|
446
|
+
async def run_imap_coro(coro, timeout):
|
|
11
447
|
'''
|
|
12
448
|
Raises or returns data.
|
|
13
449
|
'''
|
|
14
450
|
try:
|
|
15
|
-
status, data = await coro
|
|
451
|
+
status, data = await s_common.wait_for(coro, timeout)
|
|
16
452
|
except asyncio.TimeoutError:
|
|
17
|
-
raise s_exc.
|
|
453
|
+
raise s_exc.TimeOut(mesg='Timed out waiting for IMAP server response.') from None
|
|
18
454
|
|
|
19
|
-
if status
|
|
455
|
+
if status:
|
|
20
456
|
return data
|
|
21
457
|
|
|
22
458
|
try:
|
|
@@ -24,7 +460,7 @@ async def run_imap_coro(coro):
|
|
|
24
460
|
except (TypeError, AttributeError, IndexError, UnicodeDecodeError):
|
|
25
461
|
mesg = 'IMAP server returned an error'
|
|
26
462
|
|
|
27
|
-
raise s_exc.
|
|
463
|
+
raise s_exc.ImapError(mesg=mesg, status=status)
|
|
28
464
|
|
|
29
465
|
@s_stormtypes.registry.registerLib
|
|
30
466
|
class ImapLib(s_stormtypes.Lib):
|
|
@@ -72,7 +508,7 @@ class ImapLib(s_stormtypes.Lib):
|
|
|
72
508
|
'connect': self.connect,
|
|
73
509
|
}
|
|
74
510
|
|
|
75
|
-
async def connect(self, host, port=
|
|
511
|
+
async def connect(self, host, port=imaplib.IMAP4_SSL_PORT, timeout=30, ssl=True, ssl_verify=True):
|
|
76
512
|
|
|
77
513
|
self.runt.confirm(('storm', 'inet', 'imap', 'connect'))
|
|
78
514
|
|
|
@@ -82,24 +518,26 @@ class ImapLib(s_stormtypes.Lib):
|
|
|
82
518
|
ssl_verify = await s_stormtypes.tobool(ssl_verify)
|
|
83
519
|
timeout = await s_stormtypes.toint(timeout, noneok=True)
|
|
84
520
|
|
|
521
|
+
ctx = None
|
|
85
522
|
if ssl:
|
|
86
523
|
ctx = self.runt.snap.core.getCachedSslCtx(opts=None, verify=ssl_verify)
|
|
87
|
-
imap_cli = aioimaplib.IMAP4_SSL(host=host, port=port, timeout=timeout, ssl_context=ctx)
|
|
88
|
-
else:
|
|
89
|
-
imap_cli = aioimaplib.IMAP4(host=host, port=port, timeout=timeout)
|
|
90
|
-
|
|
91
|
-
async def fini():
|
|
92
|
-
# call protocol.logout() via a background task
|
|
93
|
-
s_coro.create_task(s_common.wait_for(imap_cli.protocol.logout(), 5))
|
|
94
524
|
|
|
95
|
-
|
|
525
|
+
coro = s_link.connect(host=host, port=port, ssl=ctx, linkcls=IMAPClient)
|
|
96
526
|
|
|
97
527
|
try:
|
|
98
|
-
await
|
|
528
|
+
imap = await s_common.wait_for(coro, timeout)
|
|
99
529
|
except asyncio.TimeoutError:
|
|
100
|
-
raise s_exc.
|
|
530
|
+
raise s_exc.TimeOut(mesg='Timed out waiting for IMAP server hello.') from None
|
|
101
531
|
|
|
102
|
-
|
|
532
|
+
async def fini():
|
|
533
|
+
async def _logout():
|
|
534
|
+
await s_common.wait_for(imap.logout(), 5)
|
|
535
|
+
await imap.fini()
|
|
536
|
+
s_coro.create_task(_logout())
|
|
537
|
+
|
|
538
|
+
self.runt.snap.onfini(fini)
|
|
539
|
+
|
|
540
|
+
return ImapServer(self.runt, imap, timeout)
|
|
103
541
|
|
|
104
542
|
@s_stormtypes.registry.registerType
|
|
105
543
|
class ImapServer(s_stormtypes.StormType):
|
|
@@ -277,10 +715,11 @@ class ImapServer(s_stormtypes.StormType):
|
|
|
277
715
|
)
|
|
278
716
|
_storm_typename = 'inet:imap:server'
|
|
279
717
|
|
|
280
|
-
def __init__(self, runt, imap_cli, path=None):
|
|
718
|
+
def __init__(self, runt, imap_cli, timeout, path=None):
|
|
281
719
|
s_stormtypes.StormType.__init__(self, path=path)
|
|
282
720
|
self.runt = runt
|
|
283
721
|
self.imap_cli = imap_cli
|
|
722
|
+
self.timeout = timeout
|
|
284
723
|
self.locls.update(self.getObjLocals())
|
|
285
724
|
|
|
286
725
|
def getObjLocals(self):
|
|
@@ -299,7 +738,7 @@ class ImapServer(s_stormtypes.StormType):
|
|
|
299
738
|
passwd = await s_stormtypes.tostr(passwd)
|
|
300
739
|
|
|
301
740
|
coro = self.imap_cli.login(user, passwd)
|
|
302
|
-
await run_imap_coro(coro)
|
|
741
|
+
await run_imap_coro(coro, self.timeout)
|
|
303
742
|
|
|
304
743
|
return True, None
|
|
305
744
|
|
|
@@ -308,13 +747,11 @@ class ImapServer(s_stormtypes.StormType):
|
|
|
308
747
|
reference_name = await s_stormtypes.tostr(reference_name)
|
|
309
748
|
|
|
310
749
|
coro = self.imap_cli.list(reference_name, pattern)
|
|
311
|
-
data = await run_imap_coro(coro)
|
|
750
|
+
data = await run_imap_coro(coro, self.timeout)
|
|
312
751
|
|
|
313
752
|
names = []
|
|
314
753
|
for item in data:
|
|
315
|
-
|
|
316
|
-
break
|
|
317
|
-
names.append(item.split(b' ')[-1].decode().strip('"'))
|
|
754
|
+
names.append(qsplit(item.decode())[-1])
|
|
318
755
|
|
|
319
756
|
return True, names
|
|
320
757
|
|
|
@@ -322,17 +759,18 @@ class ImapServer(s_stormtypes.StormType):
|
|
|
322
759
|
mailbox = await s_stormtypes.tostr(mailbox)
|
|
323
760
|
|
|
324
761
|
coro = self.imap_cli.select(mailbox=mailbox)
|
|
325
|
-
await run_imap_coro(coro)
|
|
762
|
+
await run_imap_coro(coro, self.timeout)
|
|
326
763
|
|
|
327
764
|
return True, None
|
|
328
765
|
|
|
329
766
|
async def search(self, *args, charset='utf-8'):
|
|
330
767
|
args = [await s_stormtypes.tostr(arg) for arg in args]
|
|
331
768
|
charset = await s_stormtypes.tostr(charset, noneok=True)
|
|
769
|
+
|
|
332
770
|
coro = self.imap_cli.uid_search(*args, charset=charset)
|
|
333
|
-
data = await run_imap_coro(coro)
|
|
771
|
+
data = await run_imap_coro(coro, self.timeout)
|
|
334
772
|
|
|
335
|
-
uids = data[0].decode()
|
|
773
|
+
uids = qsplit(data[0].decode()) if data[0] else []
|
|
336
774
|
return True, uids
|
|
337
775
|
|
|
338
776
|
async def fetch(self, uid):
|
|
@@ -344,10 +782,13 @@ class ImapServer(s_stormtypes.StormType):
|
|
|
344
782
|
await self.runt.snap.core.getAxon()
|
|
345
783
|
axon = self.runt.snap.core.axon
|
|
346
784
|
|
|
347
|
-
coro = self.imap_cli.
|
|
348
|
-
data = await run_imap_coro(coro)
|
|
785
|
+
coro = self.imap_cli.uid_fetch(str(uid), '(RFC822)')
|
|
786
|
+
data = await run_imap_coro(coro, self.timeout)
|
|
787
|
+
|
|
788
|
+
if not data:
|
|
789
|
+
return False, f'No data received from fetch request for uid {uid}.'
|
|
349
790
|
|
|
350
|
-
size, sha256b = await axon.put(data[
|
|
791
|
+
size, sha256b = await axon.put(data[0])
|
|
351
792
|
|
|
352
793
|
props = await axon.hashset(sha256b)
|
|
353
794
|
props['size'] = size
|
|
@@ -359,18 +800,18 @@ class ImapServer(s_stormtypes.StormType):
|
|
|
359
800
|
async def delete(self, uid_set):
|
|
360
801
|
uid_set = await s_stormtypes.tostr(uid_set)
|
|
361
802
|
|
|
362
|
-
coro = self.imap_cli.
|
|
363
|
-
await run_imap_coro(coro)
|
|
803
|
+
coro = self.imap_cli.uid_store(uid_set, '+FLAGS.SILENT', '(\\Deleted)')
|
|
804
|
+
await run_imap_coro(coro, self.timeout)
|
|
364
805
|
|
|
365
806
|
coro = self.imap_cli.expunge()
|
|
366
|
-
await run_imap_coro(coro)
|
|
807
|
+
await run_imap_coro(coro, self.timeout)
|
|
367
808
|
|
|
368
809
|
return True, None
|
|
369
810
|
|
|
370
811
|
async def markSeen(self, uid_set):
|
|
371
812
|
uid_set = await s_stormtypes.tostr(uid_set)
|
|
372
813
|
|
|
373
|
-
coro = self.imap_cli.
|
|
374
|
-
await run_imap_coro(coro)
|
|
814
|
+
coro = self.imap_cli.uid_store(uid_set, '+FLAGS.SILENT', '(\\Seen)')
|
|
815
|
+
await run_imap_coro(coro, self.timeout)
|
|
375
816
|
|
|
376
817
|
return True, None
|
synapse/lib/stormtypes.py
CHANGED
|
@@ -4967,6 +4967,24 @@ class Bytes(Prim):
|
|
|
4967
4967
|
{'name': 'offset', 'type': 'int', 'desc': 'An offset to begin unpacking from.', 'default': 0},
|
|
4968
4968
|
),
|
|
4969
4969
|
'returns': {'type': 'list', 'desc': 'The unpacked primitive values.', }}},
|
|
4970
|
+
{'name': 'xor', 'desc': '''
|
|
4971
|
+
Perform an "exclusive or" bitwise operation on the bytes and another set of bytes.
|
|
4972
|
+
|
|
4973
|
+
Notes:
|
|
4974
|
+
The key bytes provided as an argument will be repeated as needed until all bytes have been
|
|
4975
|
+
xor'd.
|
|
4976
|
+
|
|
4977
|
+
If a string is provided as the key argument, it will be utf8 encoded before being xor'd.
|
|
4978
|
+
|
|
4979
|
+
Examples:
|
|
4980
|
+
Perform an xor operation on the bytes in $encoded using the bytes in $key::
|
|
4981
|
+
|
|
4982
|
+
$decoded = $encoded.xor($key)''',
|
|
4983
|
+
'type': {'type': 'function', '_funcname': '_methXor',
|
|
4984
|
+
'args': (
|
|
4985
|
+
{'name': 'key', 'type': ['str', 'bytes'], 'desc': 'The key bytes to perform the xor operation with.'},
|
|
4986
|
+
),
|
|
4987
|
+
'returns': {'type': 'bytes', 'desc': "The xor'd bytes."}}},
|
|
4970
4988
|
)
|
|
4971
4989
|
_storm_typename = 'bytes'
|
|
4972
4990
|
_ismutable = False
|
|
@@ -4977,6 +4995,7 @@ class Bytes(Prim):
|
|
|
4977
4995
|
|
|
4978
4996
|
def getObjLocals(self):
|
|
4979
4997
|
return {
|
|
4998
|
+
'xor': self._methXor,
|
|
4980
4999
|
'decode': self._methDecode,
|
|
4981
5000
|
'bunzip': self._methBunzip,
|
|
4982
5001
|
'gunzip': self._methGunzip,
|
|
@@ -5065,6 +5084,26 @@ class Bytes(Prim):
|
|
|
5065
5084
|
except UnicodeDecodeError as e:
|
|
5066
5085
|
raise s_exc.StormRuntimeError(mesg=f'{e}: {s_common.trimText(repr(valu))}') from None
|
|
5067
5086
|
|
|
5087
|
+
@stormfunc(readonly=True)
|
|
5088
|
+
async def _methXor(self, key):
|
|
5089
|
+
key = await toprim(key)
|
|
5090
|
+
if isinstance(key, str):
|
|
5091
|
+
key = key.encode()
|
|
5092
|
+
|
|
5093
|
+
if not isinstance(key, bytes):
|
|
5094
|
+
raise s_exc.BadArg(mesg='$bytes.xor() key argument must be bytes or a str.')
|
|
5095
|
+
|
|
5096
|
+
if len(key) == 0:
|
|
5097
|
+
raise s_exc.BadArg(mesg='$bytes.xor() key length must be greater than 0.')
|
|
5098
|
+
|
|
5099
|
+
arry = bytearray(self.valu)
|
|
5100
|
+
keylen = len(key)
|
|
5101
|
+
|
|
5102
|
+
for i in range(len(arry)):
|
|
5103
|
+
arry[i] ^= key[i % keylen]
|
|
5104
|
+
|
|
5105
|
+
return bytes(arry)
|
|
5106
|
+
|
|
5068
5107
|
@registry.registerType
|
|
5069
5108
|
class Dict(Prim):
|
|
5070
5109
|
'''
|
synapse/lib/version.py
CHANGED
|
@@ -223,6 +223,6 @@ def reqVersion(valu, reqver,
|
|
|
223
223
|
##############################################################################
|
|
224
224
|
# The following are touched during the release process by bumpversion.
|
|
225
225
|
# Do not modify these directly.
|
|
226
|
-
version = (2,
|
|
226
|
+
version = (2, 220, 0)
|
|
227
227
|
verstring = '.'.join([str(x) for x in version])
|
|
228
|
-
commit = '
|
|
228
|
+
commit = '913156d6c195af916a32860341f285954cb4e882'
|