/
imap.py
364 lines (298 loc) · 11.6 KB
/
imap.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
import asyncio
import aioimaplib
import synapse.exc as s_exc
import synapse.lib.stormtypes as s_stormtypes
async def run_imap_coro(coro):
'''
Raises or returns data.
'''
try:
status, data = await coro
except asyncio.TimeoutError:
raise s_exc.StormRuntimeError(mesg='Timed out waiting for IMAP server response.') from None
if status == 'OK':
return data
try:
mesg = data[0].decode()
except (TypeError, AttributeError, IndexError, UnicodeDecodeError):
mesg = 'IMAP server returned an error'
raise s_exc.StormRuntimeError(mesg=mesg, status=status)
@s_stormtypes.registry.registerLib
class ImapLib(s_stormtypes.Lib):
'''
A Storm library to connect to an IMAP server.
'''
_storm_locals = (
{
'name': 'connect',
'desc': '''
Open a connection to an IMAP server.
This method will wait for a "hello" response from the server
before returning the ``inet:imap:server`` instance.
''',
'type': {
'type': 'function', '_funcname': 'connect',
'args': (
{'type': 'str', 'name': 'host',
'desc': 'The IMAP hostname.'},
{'type': 'int', 'name': 'port', 'default': 993,
'desc': 'The IMAP server port.'},
{'type': 'int', 'name': 'timeout', 'default': 30,
'desc': 'The time to wait for all commands on the server to execute.'},
{'type': 'bool', 'name': 'ssl', 'default': True,
'desc': 'Use SSL to connect to the IMAP server.'},
),
'returns': {
'type': 'inet:imap:server',
'desc': 'A new ``inet:imap:server`` instance.'
},
},
},
)
_storm_lib_path = ('inet', 'imap', )
def getObjLocals(self):
return {
'connect': self.connect,
}
async def connect(self, host, port=993, timeout=30, ssl=True):
self.runt.confirm(('storm', 'inet', 'imap', 'connect'))
ssl = await s_stormtypes.tobool(ssl)
host = await s_stormtypes.tostr(host)
port = await s_stormtypes.toint(port)
timeout = await s_stormtypes.toint(timeout, noneok=True)
if ssl:
imap_cli = aioimaplib.IMAP4_SSL(host=host, port=port, timeout=timeout)
else:
imap_cli = aioimaplib.IMAP4(host=host, port=port, timeout=timeout)
async def fini():
# call protocol.logout() so fini() doesn't hang
await asyncio.wait_for(imap_cli.protocol.logout(), 5)
self.runt.snap.onfini(fini)
try:
await imap_cli.wait_hello_from_server()
except asyncio.TimeoutError:
raise s_exc.StormRuntimeError(mesg='Timed out waiting for IMAP server hello.') from None
return ImapServer(self.runt, imap_cli)
@s_stormtypes.registry.registerType
class ImapServer(s_stormtypes.StormType):
'''
An IMAP server for retrieving email messages.
'''
_storm_locals = (
{
'name': 'list',
'desc': '''
List mailbox names.
By default this method uses a reference_name and pattern to return
all mailboxes from the root.
''',
'type': {
'type': 'function', '_funcname': 'list',
'args': (
{'type': 'str', 'name': 'reference_name', 'default': '""',
'desc': 'The mailbox reference name.'},
{'type': 'str', 'name': 'pattern', 'default': '*',
'desc': 'The pattern to filter by.'},
),
'returns': {
'type': 'list',
'desc': 'An ($ok, $valu) tuple where $valu is a list of names if $ok=True.'
},
},
},
{
'name': 'fetch',
'desc': '''
Fetch a message by UID in RFC822 format.
The message is saved to the Axon, and a ``file:bytes`` node is returned.
Examples:
Fetch a message, save to the Axon, and yield ``file:bytes`` node::
yield $server.fetch("8182")
''',
'type': {
'type': 'function', '_funcname': 'fetch',
'args': (
{'type': 'str', 'name': 'uid',
'desc': 'The single message UID.'},
),
'returns': {
'type': 'node',
'desc': 'The file:bytes node representing the message.'
},
},
},
{
'name': 'login',
'desc': 'Login to the IMAP server.',
'type': {
'type': 'function', '_funcname': 'login',
'args': (
{'type': 'str', 'name': 'user',
'desc': 'The username to login with.'},
{'type': 'str', 'name': 'passwd',
'desc': 'The password to login with.'},
),
'returns': {
'type': 'list',
'desc': 'An ($ok, $valu) tuple.'
},
},
},
{
'name': 'search',
'desc': '''
Search for messages using RFC2060 syntax.
Examples:
Retrieve all messages::
($ok, $uids) = $server.search("ALL")
Search by FROM and SINCE::
($ok, $uids) = $server.search("FROM", "visi@vertex.link", "SINCE", "01-Oct-2021")
Search by a subject substring::
($ok, $uids) = $search.search("HEADER", "Subject", "An email subject")
''',
'type': {
'type': 'function', '_funcname': 'search',
'args': (
{'type': 'str', 'name': '*args',
'desc': 'A set of search criteria to use.'},
),
'returns': {
'type': 'list',
'desc': 'An ($ok, $valu) tuple, where $valu is a list of UIDs if $ok=True.'
},
},
},
{
'name': 'select',
'desc': 'Select a mailbox to use in subsequent commands.',
'type': {
'type': 'function', '_funcname': 'select',
'args': (
{'type': 'str', 'name': 'mailbox', 'default': 'INBOX',
'desc': 'The mailbox name to select.'},
),
'returns': {
'type': 'list',
'desc': 'An ($ok, $valu) tuple.'
},
},
},
{
'name': 'markSeen',
'desc': '''
Mark messages as seen by an RFC2060 UID message set.
The command uses the +FLAGS.SILENT command and applies the \\Seen flag.
Examples:
Mark a single messsage as seen::
($ok, $valu) = $server.markSeen("8182")
Mark ranges of messages as seen::
($ok, $valu) = $server.markSeen("1:3,6:9")
''',
'type': {
'type': 'function', '_funcname': 'markSeen',
'args': (
{'type': 'str', 'name': 'uid_set',
'desc': 'The UID message set to apply the flag to.'},
),
'returns': {
'type': 'list',
'desc': 'An ($ok, $valu) tuple.'
},
},
},
{
'name': 'delete',
'desc': '''
Mark an RFC2060 UID message as deleted and expunge the mailbox.
The command uses the +FLAGS.SILENT command and applies the \\Deleted flag.
The actual behavior of these commands are mailbox configuration dependent.
Examples:
Mark a single message as deleted and expunge::
($ok, $valu) = $server.delete("8182")
Mark ranges of messages as deleted and expunge::
($ok, $valu) = $server.delete("1:3,6:9")
''',
'type': {
'type': 'function', '_funcname': 'delete',
'args': (
{'type': 'str', 'name': 'uid_set',
'desc': 'The UID message set to apply the flag to.'},
),
'returns': {
'type': 'list',
'desc': 'An ($ok, $valu) tuple.'
},
},
},
)
_storm_typename = 'inet:imap:server'
def __init__(self, runt, imap_cli, path=None):
s_stormtypes.StormType.__init__(self, path=path)
self.runt = runt
self.imap_cli = imap_cli
self.locls.update(self.getObjLocals())
def getObjLocals(self):
return {
'list': self.list,
'fetch': self.fetch,
'login': self.login,
'delete': self.delete,
'search': self.search,
'select': self.select,
'markSeen': self.markSeen,
}
async def login(self, user, passwd):
user = await s_stormtypes.tostr(user)
passwd = await s_stormtypes.tostr(passwd)
coro = self.imap_cli.login(user, passwd)
await run_imap_coro(coro)
return True, None
async def list(self, reference_name='""', pattern='*'):
pattern = await s_stormtypes.tostr(pattern)
reference_name = await s_stormtypes.tostr(reference_name)
coro = self.imap_cli.list(reference_name, pattern)
data = await run_imap_coro(coro)
names = []
for item in data:
if item == b'Success':
break
names.append(item.split(b' ')[-1].decode().strip('"'))
return True, names
async def select(self, mailbox='INBOX'):
mailbox = await s_stormtypes.tostr(mailbox)
coro = self.imap_cli.select(mailbox=mailbox)
await run_imap_coro(coro)
return True, None
async def search(self, *args):
args = [await s_stormtypes.tostr(arg) for arg in args]
coro = self.imap_cli.uid_search(*args)
data = await run_imap_coro(coro)
uids = data[0].decode().split(' ') if data[0] else []
return True, uids
async def fetch(self, uid):
# IMAP fetch accepts a message set (e.g. "1", "1:*", "1,2,3"),
# however this method forces fetching a single uid
# to prevent retrieving a very large blob of data.
uid = await s_stormtypes.toint(uid)
await self.runt.snap.core.getAxon()
axon = self.runt.snap.core.axon
coro = self.imap_cli.uid('FETCH', str(uid), '(RFC822)')
data = await run_imap_coro(coro)
size, sha256b = await axon.put(data[1])
props = await axon.hashset(sha256b)
props['size'] = size
props['mime'] = 'message/rfc822'
filenode = await self.runt.snap.addNode('file:bytes', props['sha256'], props=props)
return filenode
async def delete(self, uid_set):
uid_set = await s_stormtypes.tostr(uid_set)
coro = self.imap_cli.uid('STORE', uid_set, '+FLAGS.SILENT (\\Deleted)')
await run_imap_coro(coro)
coro = self.imap_cli.expunge()
await run_imap_coro(coro)
return True, None
async def markSeen(self, uid_set):
uid_set = await s_stormtypes.tostr(uid_set)
coro = self.imap_cli.uid('STORE', uid_set, '+FLAGS.SILENT (\\Seen)')
await run_imap_coro(coro)
return True, None