Skip to content

Commit a1d5691

Browse files
committed
print received messages
1 parent 3ec2e2c commit a1d5691

8 files changed

Lines changed: 136 additions & 33 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
[![Build Status](https://travis-ci.org/srittau/FakeSMTPd.svg?branch=master)](https://travis-ci.org/srittau/FakeSMTPd)
44

55
FakeSMTPd is an SMTP server for testing mail functionality. Any mail sent via
6-
this server will be printed to stdout, but will not be forwarded any further.
6+
this server will be saved, but will not be forwarded any further.
7+
8+
Mail is printed to stdout by default in default mbox format, as defined in
9+
[RFC 4155](https://www.ietf.org/rfc/rfc4155.txt).
710

811
Usage
912
-----

fakesmtpd/commands.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,15 @@ def handle_helo(state: State, arguments: str) -> Reply:
3434

3535

3636
def handle_mail(state: State, arguments: str) -> Reply:
37-
if not re.match(r"^FROM:<.*>", arguments, re.IGNORECASE):
37+
m = re.match(r"^FROM:<(.*)>", arguments, re.IGNORECASE)
38+
if not m:
3839
return handle_wrong_arguments()
3940
if not state.greeted:
4041
return handle_no_greeting()
4142
if not state.mail_allowed:
4243
return handle_bad_command_sequence()
4344
state.clear()
44-
state.reverse_path = ""
45+
state.reverse_path = m.group(1)
4546
return SMTPStatus.OK, "Sender OK"
4647

4748

@@ -57,11 +58,12 @@ def handle_quit(state: State, arguments: str) -> Reply:
5758

5859

5960
def handle_rcpt(state: State, arguments: str) -> Reply:
60-
if not re.match(r"^TO:<.*>", arguments, re.IGNORECASE):
61+
m = re.match(r"^TO:<(.*)>", arguments, re.IGNORECASE)
62+
if not m:
6163
return handle_wrong_arguments()
6264
if not state.rcpt_allowed:
6365
return handle_bad_command_sequence()
64-
state.forward_path = []
66+
state.add_forward_path(m.group(1))
6567
return SMTPStatus.OK, "Receiver OK"
6668

6769

fakesmtpd/connection.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import datetime
12
from asyncio.streams import StreamReader, StreamWriter
23
import logging
34
from socket import getfqdn
4-
from typing import Tuple
5+
from typing import Tuple, Callable
56

67
from fakesmtpd.commands import handle_command
78
from fakesmtpd.smtp import SMTPStatus
@@ -15,9 +16,11 @@ class UnexpectedEOFError(Exception):
1516

1617
class ConnectionHandler:
1718

18-
def __init__(self, reader: StreamReader, writer: StreamWriter) -> None:
19+
def __init__(self, reader: StreamReader, writer: StreamWriter,
20+
print_mail: Callable[[State], None]) -> None:
1921
self.reader = reader
2022
self.writer = writer
23+
self.print_mail = print_mail
2124
self.state = State()
2225

2326
async def handle(self) -> None:
@@ -51,22 +54,20 @@ async def _handle_mail_text(self):
5154
pass
5255
else:
5356
self._write_reply(SMTPStatus.OK, "OK")
54-
self.state.clear()
57+
self.state.date = datetime.datetime.utcnow()
58+
state = self.state
59+
self.print_mail(state)
60+
self.state = State()
61+
self.state.greeted = state.greeted
5562

56-
async def _read_mail_text(self) -> str:
57-
text = ""
63+
async def _read_mail_text(self) -> None:
5864
while not self.reader.at_eof():
5965
line = await self.reader.readline()
6066
if line == b".\r\n":
61-
return text
62-
text += line.decode("ascii")
67+
return
68+
self.state.add_line(line.decode("ascii"))
6369
raise UnexpectedEOFError()
6470

6571
def _write_reply(self, code: SMTPStatus, text: str) -> None:
6672
full_line = f"{code.value} {text}\r\n"
6773
self.writer.write(full_line.encode("ascii"))
68-
69-
70-
async def handle_connection(reader: StreamReader, writer: StreamWriter) \
71-
-> None:
72-
await ConnectionHandler(reader, writer).handle()

fakesmtpd/mbox.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from fakesmtpd.state import State
2+
3+
4+
def print_mbox_mail(stream, state: State) -> None:
5+
"""Print a mail in RFC 4155 default mbox format."""
6+
stream.write(f"From {state.reverse_path} {state.date.ctime()}\n")
7+
stream.write(state.mail_data.replace("\r\n", "\n"))
8+
stream.write("\n")
9+
stream.flush()

fakesmtpd/server.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import asyncio
2+
from asyncio.streams import StreamReader, StreamWriter
23
import logging
34
import signal
45
import sys
56
from typing import Optional
67

8+
from functools import partial
9+
710
from fakesmtpd.args import parse_args
8-
from fakesmtpd.connection import handle_connection
11+
from fakesmtpd.connection import ConnectionHandler
12+
from fakesmtpd.mbox import print_mbox_mail
913

1014

1115
def main() -> None:
@@ -25,3 +29,9 @@ def run_server(host: Optional[str], port: int) -> None:
2529
s = asyncio.start_server(handle_connection, host=host, port=port)
2630
loop.run_until_complete(s)
2731
loop.run_forever()
32+
33+
34+
async def handle_connection(reader: StreamReader, writer: StreamWriter) \
35+
-> None:
36+
print_it = partial(print_mbox_mail, sys.stdout)
37+
await ConnectionHandler(reader, writer, print_it).handle()

fakesmtpd/state.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,33 @@
1+
import datetime
2+
from typing import Optional, List
3+
4+
15
class State:
26

37
def __init__(self) -> None:
48
self.greeted = False
5-
self.clear()
9+
self.date: Optional[datetime.datetime] = None
10+
self.reverse_path: Optional[str] = None
11+
self.forward_path: Optional[List[str]] = None
12+
self.mail_data: Optional[str] = None
613

714
def clear(self) -> None:
8-
self.reverse_path = None
9-
self.forward_path = None
10-
self.mail_data = None
15+
self.reverse_path: Optional[str] = None
16+
self.forward_path: Optional[List[str]] = None
17+
self.mail_data: Optional[str] = None
18+
19+
def add_forward_path(self, path: str) -> None:
20+
if self.forward_path is None:
21+
self.forward_path = []
22+
self.forward_path.append(path)
23+
24+
def add_line(self, line: str) -> None:
25+
if self.mail_data is None:
26+
self.mail_data = ""
27+
self.mail_data += line
1128

1229
@property
13-
def mail_allowed(self):
30+
def mail_allowed(self) -> bool:
1431
return (
1532
self.greeted and
1633
self.reverse_path is None and
@@ -19,15 +36,15 @@ def mail_allowed(self):
1936
)
2037

2138
@property
22-
def rcpt_allowed(self):
39+
def rcpt_allowed(self) -> bool:
2340
return (
2441
self.greeted and
2542
self.reverse_path is not None and
2643
self.mail_data is None
2744
)
2845

2946
@property
30-
def data_allowed(self):
47+
def data_allowed(self) -> bool:
3148
return (
3249
self.greeted and
3350
self.reverse_path is not None and

fakesmtpd_test/connection.py

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import asyncio
2-
from typing import List
3-
from unittest.case import TestCase
2+
from typing import List, Optional
3+
from unittest import TestCase
44
from unittest.mock import patch
55

6-
from asserts import assert_equal, assert_greater_equal, fail
6+
from asserts import assert_equal, assert_greater_equal, fail, \
7+
assert_is_not_none, assert_datetime_about_now_utc
78

8-
from fakesmtpd.connection import handle_connection
9+
from fakesmtpd.connection import ConnectionHandler
910
from fakesmtpd.smtp import SMTPStatus
11+
from fakesmtpd.state import State
1012

1113
FAKE_HOST = "mail.example.com"
1214

@@ -78,15 +80,19 @@ def setUp(self):
7880
patch("fakesmtpd.commands.getfqdn", lambda: FAKE_HOST)
7981
self._getfqdn_patch1.start()
8082
self._getfqdn_patch2.start()
83+
self.printed_state: Optional[State] = None
8184

8285
def tearDown(self):
8386
self._getfqdn_patch1.stop()
8487
self._getfqdn_patch2.stop()
8588

8689
def _handle(self):
8790
loop = asyncio.get_event_loop()
88-
c = handle_connection(self.reader, self.writer)
89-
loop.run_until_complete(c)
91+
handler = ConnectionHandler(self.reader, self.writer, self._print_mail)
92+
loop.run_until_complete(handler.handle())
93+
94+
def _print_mail(self, state: State) -> None:
95+
self.printed_state = state
9096

9197
def test_greeting(self):
9298
self._handle()
@@ -311,7 +317,7 @@ def test_complete_mail(self):
311317
"From: foo@example.com",
312318
"To: bar@example.com",
313319
"Subject: Foobar",
314-
""
320+
"",
315321
"Line 1",
316322
"Line 2",
317323
".",
@@ -328,7 +334,7 @@ def test_two_transactions(self):
328334
"From: foo@example.com",
329335
"To: bar@example.com",
330336
"Subject: Foobar",
331-
""
337+
"",
332338
".",
333339
"MAIL FROM:<foo@example.com>",
334340
]
@@ -376,3 +382,33 @@ def test_vrfy(self):
376382
self._handle()
377383
self.writer.assert_last_reply(
378384
SMTPStatus.CANNOT_VRFY, "Verify not allowed")
385+
386+
def test_mail_printed(self):
387+
self.reader.lines = [
388+
"EHLO client.example.com",
389+
"MAIL FROM:<foo@example.com>",
390+
"RCPT TO:<bar1@example.com>",
391+
"RCPT TO:<bar2@example.com>",
392+
"DATA",
393+
"From: foo@example.com",
394+
"To: bar@example.com",
395+
"Subject: Foobar",
396+
"",
397+
"Line 1 ",
398+
"Line 2",
399+
".",
400+
]
401+
self._handle()
402+
assert_is_not_none(self.printed_state)
403+
assert_datetime_about_now_utc(self.printed_state.date)
404+
assert_equal("foo@example.com", self.printed_state.reverse_path)
405+
assert_equal(["bar1@example.com", "bar2@example.com"],
406+
self.printed_state.forward_path)
407+
assert_equal(
408+
"From: foo@example.com\r\n"
409+
"To: bar@example.com\r\n"
410+
"Subject: Foobar\r\n"
411+
"\r\n"
412+
"Line 1 \r\n"
413+
"Line 2\r\n",
414+
self.printed_state.mail_data)

fakesmtpd_test/mbox.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import datetime
2+
3+
from asserts import assert_equal
4+
from io import StringIO
5+
from unittest import TestCase
6+
7+
from fakesmtpd.mbox import print_mbox_mail
8+
from fakesmtpd.state import State
9+
10+
11+
class PrintMboxMailTest(TestCase):
12+
13+
def test_print(self):
14+
out = StringIO()
15+
state = State()
16+
state.date = datetime.datetime(2017, 6, 4, 14, 34, 15)
17+
state.reverse_path = "sender@example.com"
18+
state.forward_path = ["receiver1@example.com", "receiver2@example.com"]
19+
state.mail_data = "Subject: Foo\r\n\r\nText\r\n"
20+
print_mbox_mail(out, state)
21+
assert_equal("From sender@example.com Sun Jun 4 14:34:15 2017\n"
22+
"Subject: Foo\n"
23+
"\n"
24+
"Text\n"
25+
"\n", out.getvalue())

0 commit comments

Comments
 (0)