-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathmodels.py
365 lines (323 loc) · 13.1 KB
/
models.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
365
"""Definitions of model classes, that wrap Mailpit's API data-structures. Defined with
:py:mod:`dataclasses` and :py:mod:`dataclasses_json`, in order to use them as json over
the API and be used as objects in the Python domain."""
import dataclasses
import datetime
import decimal
import email.utils as email
import logging
import re
from typing import Optional, Iterable
import dataclasses_json
import marshmallow.fields
_log = logging.getLogger("mailpit_client")
@dataclasses_json.dataclass_json
@dataclasses.dataclass(init=True)
class Contact:
"""Represents a mail contact splitting 'Test User <[email protected]> into
its name and address parts"""
# pylint: disable=too-few-public-methods
name: str = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="Name")
)
"""Contact's Name"""
address: str = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="Address")
)
"""Contact's E-Mail address"""
def __lt__(self, other):
return f"{other.name} {other.address}".__lt__(f"{self.name} {self.address}")
def __hash__(self):
return f"{self.name} {self.address}".__hash__()
@dataclasses.dataclass(init=True)
class Attachment(dataclasses_json.DataClassJsonMixin):
"""Represents an attachment of a :py:class:`Message`"""
# pylint: disable=too-few-public-methods
part_id: str = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="PartID")
)
"""Attachment's part ID"""
file_name: str = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="FileName")
)
"""Attachment's file name"""
content_type: str = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="ContentType")
)
"""Attachment's MIME content-type"""
content_id: str = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="ContentID")
)
size: int = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="Size")
)
"""Attachment's size in bytes"""
def __lt__(self, other: "Attachment"):
return (
f"{other.part_id} {other.file_name} {other.content_type} "
f"{other.content_id} {other.size}".__lt__(
f"{self.part_id} {self.file_name} {self.content_type} "
f"{self.content_id} {self.size}"
)
)
def __hash__(self):
return (
f"{self.part_id} {self.file_name} {self.content_type} "
f"{self.content_id} {self.size}"
).__hash__()
@dataclasses.dataclass(init=True)
class Message(dataclasses_json.DataClassJsonMixin):
"""Represents a message returned by the message-endpoint"""
# pylint: disable=too-many-instance-attributes
# pylint: disable=too-few-public-methods
# pylint: disable=invalid-name
id: str = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="ID")
)
"""Message's database ID, of Mailpit's message-database"""
message_id: str = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="MessageID")
)
"""Message's RFC-5322 message-id"""
read: bool = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="Read")
)
"""Always true (message marked read on open)"""
from_: Optional[Contact] = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="From")
)
"""The :py:class`Contact`: the message is from"""
to: list[Contact] = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="To")
)
"""Message's To-Header, the list of :Contact: the message is addressed to"""
cc: Optional[list[Contact]] = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="Cc")
)
"""Message's CC-Header, the list of :Contact: that the message is coal-copied to"""
bcc: Optional[list[Contact]] = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="Bcc")
)
"""Message's BCC-Header, the list of :Contact:, that the message is blindly
coal-copied to"""
subject: str = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="Subject")
)
"""Message's subject"""
date: datetime.date = dataclasses.field(
init=True,
metadata=dataclasses_json.config(
field_name="Date",
encoder=datetime.datetime.isoformat,
decoder=datetime.datetime.fromisoformat,
mm_field=marshmallow.fields.DateTime("iso"),
),
)
"""Parsed email local date & time from headers"""
text: Optional[str] = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="Text")
)
"""Plain text MIME part of the email"""
html: Optional[str] = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="HTML")
)
"""HTML MIME part (if exists)"""
size: int = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="Size")
)
"""Total size of raw email in bytes"""
inline: list[Attachment] = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="Inline")
)
"""Inline Attachments"""
attachments: list[Attachment] = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="Attachments")
)
"""Attachments"""
def __eq__(self, other):
"""check if a message is equal to another message. Fields not included are
Mailpit's Database-ID because it might not be known and the size in bytes,
because it might be differently depending on the way messages are saved
:returns: :py:class:`True` if two messages are equal, :py:class:`False` if not
"""
if not isinstance(other, Message):
raise NotImplementedError
if other is None:
return False
if other.message_id is None and self.message_id is not None:
return False
if other is not None and self.message_id is None:
return False
if other.message_id is not None and self.message_id is not None:
if other.message_id != self.message_id:
return False
if other.from_ != self.from_:
return False
if other.subject != self.subject:
return False
if other.date != self.date:
return False
if other.text != self.text:
return False
if other.html != self.html:
return False
if set(sorted(other.to)).difference(sorted(self.to)):
return False
if (
other.cc is None
and self.cc is not None
or other.cc is not None
and self.cc is None
):
return False
if len(other.cc) != len(self.cc):
return False
if set(sorted(other.cc)).difference(sorted(self.cc)):
return False
if (
other.bcc is None
and self.bcc is not None
or other.bcc is not None
and self.bcc is None
):
return False
if len(other.bcc) != len(self.bcc):
return False
if set(sorted(other.bcc)).difference(sorted(self.bcc)) or set(
sorted(self.bcc)
).difference(sorted(other.bcc)):
return False
if len(other.inline) != len(self.inline):
return False
if set(sorted(other.inline)).difference(sorted(self.inline)) or set(
sorted(self.inline)
).difference(sorted(other.inline)):
return False
if len(other.attachments) != len(self.attachments):
return False
if set(sorted(other.attachments)).difference(sorted(self.attachments)) or set(
sorted(self.attachments)
).difference(sorted(other.attachments)):
return False
return True
def datelist_encoder(encodes: Iterable[datetime.datetime]) -> list[str]:
return list(map(lambda encode: email.format_datetime(encode), encodes))
def datelist_decoder(decodes: Iterable[str]) -> list[datetime.datetime]:
return list(map(lambda decode: email.parsedate_to_datetime(decode), decodes))
@dataclasses_json.dataclass_json(undefined=dataclasses_json.Undefined.INCLUDE)
@dataclasses.dataclass(init=True)
class Headers:
content_type: list[str] = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="Content-Type")
)
date: list[datetime.date] = dataclasses.field(
init=True,
metadata=dataclasses_json.config(
field_name="Date",
encoder=datelist_encoder,
decoder=datelist_decoder,
mm_field=marshmallow.fields.List(marshmallow.fields.DateTime("iso")),
),
)
delivered_to: list[str] = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="Delivered-To")
)
from_: list[str] = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="From")
)
message_id: list[str] = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="Message-Id")
)
additional: dataclasses_json.CatchAll = dataclasses.field(init=True)
def millis_to_3_digit(isoformat: str) -> str:
"""replaces milliseconds with
three-digits long value using zero padding before"""
millis = re.search(r"\.\d{0,3}", isoformat)
if not millis:
seconds = re.search(r":\d+(:\d+)", isoformat)
_log.debug(f"seconds: {seconds}")
if seconds is None:
raise ValueError("seconds may not be None")
return isoformat.replace(seconds.group(0), f"{seconds.group(0)}.000")
_log.debug(f"millis: {millis.group(0)}, {decimal.Decimal(millis.group(0)):.03f}")
return isoformat.replace(
f"{millis.group(0)}", f".{int(decimal.Decimal(millis.group(0)) * 1000):03}"
)
def zulu_to_utc_shift(isoformat: str) -> str:
"""replaces 'Z' with '+00:00' for UTC"""
return isoformat.replace("Z", "+00:00")
def datetime_decoder(isoformat: str) -> datetime.datetime:
"""replaces golang isoformat with Python parsable isoformat
and decodes it to `datetime.datetime`"""
_log.debug(f"old isdoformat: {isoformat}")
result = zulu_to_utc_shift(millis_to_3_digit(isoformat))
_log.debug(f"new isoformat: {result}")
return datetime.datetime.fromisoformat(result)
@dataclasses.dataclass(init=True)
class MessageSummary(dataclasses_json.DataClassJsonMixin):
"""class representing a single message that has been returned by the messages
endpoint"""
# pylint: disable=too-many-instance-attributes
# pylint: disable=too-few-public-methods
# pylint: disable=invalid-name
id: str = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="ID")
)
message_id: str = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="MessageID")
)
read: bool = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="Read")
)
"""always true (message marked read on open)"""
from_: Optional[Contact] = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="From")
)
to: list[Contact] = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="To")
)
cc: Optional[list[Contact]] = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="Cc")
)
bcc: list[Contact] = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="Bcc")
)
subject: str = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="Subject")
)
"""Message subject"""
created: datetime.date = dataclasses.field(
init=True,
metadata=dataclasses_json.config(
field_name="Created",
encoder=datetime.datetime.isoformat,
decoder=datetime_decoder,
mm_field=marshmallow.fields.DateTime("iso"),
),
)
"""Parsed email local date & time from headers"""
size: int = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="Size")
)
"""Total size of raw email"""
attachments: int = dataclasses.field(
init=True, metadata=dataclasses_json.config(field_name="Attachments")
)
@dataclasses.dataclass(init=True)
class Messages(dataclasses_json.DataClassJsonMixin):
# pylint: disable=too-few-public-methods
"""class representing the returns of the messages endpoint"""
total: int = dataclasses.field(init=True)
"""Total messages in mailbox"""
unread: int = dataclasses.field(init=True)
"""Total unread messages in mailbox"""
count: int = dataclasses.field(init=True)
"""Number of messages returned in request"""
start: int = dataclasses.field(init=True)
"""The offset (default=0) for pagination"""
messages: list[MessageSummary]
def __post_init__(self):
if self.total < 0:
raise ValueError("field 'total' may not be negative")
if self.unread < 0:
raise ValueError("field 'unread' may not be negative")