Skip to content

SMTP Protocol

This section provides an overview of the SMTPProtocol class and related functionality implemented in the smtp_protocol.py file. It describes how the SMTP protocol is handled, including command parsing, response formatting, and state management.

SMTPProtocol Class

The SMTPProtocol class manages the state and communication for the SMTP protocol. It is responsible for handling SMTP commands, managing session states, and generating appropriate responses.

smtp_protocol.SMTPProtocol

Bases: LineReceiver

A class representing the SMTP protocol for handling email transmission commands and responses.

Source code in src/smtp_protocol.py
 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
366
367
368
369
370
class SMTPProtocol(LineReceiver):
    """
    A class representing the SMTP protocol for handling email transmission commands and responses.
    """

    def __init__(self, factory, debug=False):
        """
        Initialize the SMTPProtocol instance, setting up AI services and responses.

        Args:
            factory (SMTPFactory): The factory instance that created this protocol.
            debug (bool): Enables or disables debug mode.
        """
        self.factory = factory
        self.ip = None
        self.debug = debug or debug_mode
        self.ai_service = AIService(debug_mode=self.debug)
        try:
            responses = self.ai_service.load_responses("smtp")
            self.responses = self._format_responses(responses)
            if self.debug:
                logger.debug(f"Loaded and formatted responses: {self.responses}")
        except Exception as e:
            logger.error(f"Error loading SMTP responses: {e}")
            self.responses = self.default_responses()
        self.state = 'INITIAL'
        self.data_buffer = []
        self.auth_step = None
        self.auth_username = None
        self.auth_password = None

    def connectionMade(self):
        """
        Handle new connections, sending a welcome banner and logging the interaction.
        """
        self.ip = self.transport.getPeer().host

        # Check if IP is blacklisted
        if self.ip in self.factory.blacklist:
            logger.info(f"Connection attempt from blacklisted IP: {self.ip}")
            self.transport.loseConnection()
            return

        # Implement rate limiting
        if not self.factory.allow_connection(self.ip):
            logger.info(f"Rate limit exceeded for IP: {self.ip}")
            self.transport.loseConnection()
            return

        banner = self._get_banner()
        logger.info(f"Connection from {self.ip}")
        self.sendLine(banner.encode('utf-8'))
        log_interaction(self.ip, 'WELCOME', banner)

    def _get_banner(self):
        """
        Get the SMTP banner message to send upon connection.

        Returns:
            str: The banner message based on the server technology.
        """
        if technology.lower() == 'exchange':
            current_date = datetime.now().strftime("%a, %d %b %Y %H:%M:%S %z")
            return f"220 {domain_name} Microsoft ESMTP MAIL Service ready at {current_date}"
        return self.responses.get("220", f"220 {domain_name} ESMTP")

    def lineReceived(self, line):
        """
        Handle received lines of data, processing SMTP commands and managing state transitions.

        Args:
            line (bytes): The line of data received.
        """
        try:
            try:
                command = line.decode('utf-8').strip()
            except UnicodeDecodeError:
                command = line.decode('latin-1').strip()
            logger.info(f"Received command from {self.ip}: {command}")

            if self.state == 'DATA':
                if command == ".":
                    self.state = 'INITIAL'
                    data_message = "\n".join(self.data_buffer)
                    logger.info(f"Received DATA from {self.ip}:\n{data_message}")
                    self.data_buffer = []
                    response = self.responses.get("250-DATA", "250 OK: Queued")
                else:
                    self.data_buffer.append(command)
                    return
            elif self.auth_step:
                response = self._handle_auth(command)
                if response == '':
                    return  # Wait for next authentication input
            else:
                response = self._get_response(command)
                if response == '':
                    return  # Already handled in _get_response

            if command_upper := command.upper():
                if command_upper.startswith("QUIT"):
                    self.sendLine(response.encode('utf-8'))
                    log_interaction(self.ip, command, response)
                    self.transport.loseConnection()
                    return

            self.sendLine(response.encode('utf-8'))
            log_interaction(self.ip, command, response)
        except Exception as e:
            logger.error(f"Error processing command from {self.ip}: {e}")
            self.sendLine(b"500 Command unrecognized")

    def _get_response(self, command):
        """
        Generate the appropriate SMTP response for a given command.

        Args:
            command (str): The SMTP command received from the client.

        Returns:
            str: The SMTP response to be sent back to the client.
        """
        command_upper = command.upper()
        if command_upper.startswith("EHLO"):
            response = [self.responses.get("250-EHLO", f"250-{domain_name} Hello [{self.ip}]")]
            capabilities = [
                "SIZE 37748736",
                "PIPELINING",
                "DSN",
                "ENHANCEDSTATUSCODES",
                "STARTTLS",
                "AUTH LOGIN PLAIN",
                "8BITMIME",
                "SMTPUTF8",
            ]
            response.extend([f"250-{cap}" for cap in capabilities[:-1]])
            response.append(f"250 {capabilities[-1]}")
            return "\n".join(response)
        elif command_upper.startswith("HELO"):
            return self.responses.get("250-HELO", f"250 {domain_name}")
        elif command_upper.startswith("MAIL FROM"):
            return self.responses.get("250-MAIL FROM", "250 2.1.0 Sender OK")
        elif command_upper.startswith("RCPT TO"):
            return self.responses.get("250-RCPT TO", "250 2.1.5 Recipient OK")
        elif command_upper == "DATA":
            self.state = 'DATA'
            return self.responses.get("354", "354 End data with <CR><LF>.<CR><LF>")
        elif command_upper == "RSET":
            self.reset_state()
            return self.responses.get("250", "250 OK")
        elif command_upper.startswith("VRFY"):
            return self.responses.get("252", "252 Cannot VRFY user, but will accept message and attempt delivery")
        elif command_upper.startswith("EXPN"):
            return self.responses.get("502", "502 Command not implemented")
        elif command_upper == "NOOP":
            return self.responses.get("250", "250 OK")
        elif command_upper.startswith("HELP"):
            return self.responses.get("214", "214 Help message")
        elif command_upper.startswith("QUIT"):
            return self.responses.get("221", f"221 {domain_name} Service closing transmission channel")
        elif command_upper.startswith("AUTH LOGIN"):
            self.sendLine(b'334 VXNlcm5hbWU6')  # 'Username:' in Base64
            self.auth_step = 'USERNAME'
            return ''
        elif command_upper.startswith("AUTH PLAIN"):
            # Handle AUTH PLAIN with credentials in the same line
            try:
                parts = command.split(' ', 2)
                if len(parts) < 3:
                    self.sendLine(b'334 ')  # Prompt for credentials
                    self.auth_step = 'AUTH_PLAIN'
                    return ''
                credentials = parts[2]
                decoded_credentials = base64.b64decode(credentials).decode('utf-8')
                _, username, password = decoded_credentials.split('\x00')
                if check_credentials(username, password):
                    return self.responses.get("235", "235 Authentication successful")
                else:
                    return self.responses.get("535", "535 Authentication failed")
            except Exception as e:
                logger.error(f"Error during AUTH PLAIN from {self.ip}: {e}")
                return self.responses.get("535", "535 Authentication failed")
        else:
            return self.responses.get("500", "500 Command unrecognized")

    def _handle_auth(self, data):
        """
        Handle the authentication process for AUTH LOGIN.

        Args:
            data (str): The base64-encoded data received from the client.

        Returns:
            str: The SMTP response to be sent back to the client.
        """
        try:
            decoded_data = base64.b64decode(data.strip()).decode('utf-8')
            if self.auth_step == 'USERNAME':
                self.auth_username = decoded_data
                self.sendLine(b'334 UGFzc3dvcmQ6')  # 'Password:' in Base64
                self.auth_step = 'PASSWORD'
                return ''
            elif self.auth_step == 'PASSWORD':
                self.auth_password = decoded_data
                if check_credentials(self.auth_username, self.auth_password):
                    self.auth_step = None
                    self.auth_username = None
                    self.auth_password = None
                    return self.responses.get("235", "235 Authentication successful")
                else:
                    self.auth_step = None
                    self.auth_username = None
                    self.auth_password = None
                    return self.responses.get("535", "535 Authentication failed")
            elif self.auth_step == 'AUTH_PLAIN':
                # Handle continuation of AUTH PLAIN if credentials were not provided initially
                decoded_credentials = base64.b64decode(data.strip()).decode('utf-8')
                _, username, password = decoded_credentials.split('\x00')
                if check_credentials(username, password):
                    self.auth_step = None
                    return self.responses.get("235", "235 Authentication successful")
                else:
                    self.auth_step = None
                    return self.responses.get("535", "535 Authentication failed")
        except Exception as e:
            logger.error(f"Error during authentication from {self.ip}: {e}")
            self.auth_step = None
            return self.responses.get("535", "535 Authentication failed")

    def reset_state(self):
        """
        Reset the protocol state to INITIAL, clearing any buffers or authentication steps.
        """
        self.state = 'INITIAL'
        self.data_buffer = []
        self.auth_step = None
        self.auth_username = None
        self.auth_password = None

    def _format_responses(self, responses):
        """
        Format the loaded responses into a dictionary.

        Args:
            responses (dict or str): The dictionary or string of responses loaded from the AI service.

        Returns:
            dict: The formatted responses as a dictionary.
        """
        formatted_responses = {}

        # If responses are a string, attempt to parse it as JSON
        if isinstance(responses, str):
            try:
                responses = json.loads(responses)
            except json.JSONDecodeError as e:
                logger.error(f"Failed to parse JSON: {e}")
                return formatted_responses

        # If responses is a dictionary, process accordingly
        if isinstance(responses, dict):
            if "SMTP_Responses" in responses:
                # Handle list of dictionaries
                for item in responses["SMTP_Responses"]:
                    code = item.get('code')
                    message = item.get('message')
                    if code and message:
                        formatted_responses[code] = f"{code} {message}"
            elif "SMTP_Response_Codes" in responses:
                # Handle dictionary of codes
                for code, message in responses["SMTP_Response_Codes"].items():
                    formatted_responses[code] = f"{code} {message}"
            else:
                logger.error(f"Unexpected responses format: {responses}")
        else:
            logger.error(f"Unexpected type for responses: {type(responses)}")

        # Add default responses for additional commands if not provided
        default_additional_responses = {
            "252": "252 Cannot VRFY user, but will accept message and attempt delivery",
            "502": "502 Command not implemented",
            "214": "214 Help message",
        }
        for code, message in default_additional_responses.items():
            formatted_responses.setdefault(code, message)

        return formatted_responses

    def default_responses(self):
        """
        Provide default SMTP responses in case AI-generated responses are unavailable.

        Returns:
            dict: A dictionary of default SMTP responses.
        """
        return {
            "220": f"220 {domain_name} ESMTP",
            "221": f"221 {domain_name} Service closing transmission channel",
            "235": "235 Authentication successful",
            "250": f"250 {domain_name}",
            "250-EHLO": f"250-{domain_name} Hello [{self.ip}]",
            "250-HELO": f"250 {domain_name}",
            "250-MAIL FROM": "250 2.1.0 Sender OK",
            "250-RCPT TO": "250 2.1.5 Recipient OK",
            "250-DATA": "250 OK: Queued",
            "252": "252 Cannot VRFY user, but will accept message and attempt delivery",
            "502": "502 Command not implemented",
            "214": "214 Help message",
            "354": "354 End data with <CR><LF>.<CR><LF>",
            "500": "500 Command unrecognized",
            "535": "535 Authentication failed",
        }

__init__(factory, debug=False)

Initialize the SMTPProtocol instance, setting up AI services and responses.

Parameters:

Name Type Description Default
factory SMTPFactory

The factory instance that created this protocol.

required
debug bool

Enables or disables debug mode.

False
Source code in src/smtp_protocol.py
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
def __init__(self, factory, debug=False):
    """
    Initialize the SMTPProtocol instance, setting up AI services and responses.

    Args:
        factory (SMTPFactory): The factory instance that created this protocol.
        debug (bool): Enables or disables debug mode.
    """
    self.factory = factory
    self.ip = None
    self.debug = debug or debug_mode
    self.ai_service = AIService(debug_mode=self.debug)
    try:
        responses = self.ai_service.load_responses("smtp")
        self.responses = self._format_responses(responses)
        if self.debug:
            logger.debug(f"Loaded and formatted responses: {self.responses}")
    except Exception as e:
        logger.error(f"Error loading SMTP responses: {e}")
        self.responses = self.default_responses()
    self.state = 'INITIAL'
    self.data_buffer = []
    self.auth_step = None
    self.auth_username = None
    self.auth_password = None

connectionMade()

Handle new connections, sending a welcome banner and logging the interaction.

Source code in src/smtp_protocol.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def connectionMade(self):
    """
    Handle new connections, sending a welcome banner and logging the interaction.
    """
    self.ip = self.transport.getPeer().host

    # Check if IP is blacklisted
    if self.ip in self.factory.blacklist:
        logger.info(f"Connection attempt from blacklisted IP: {self.ip}")
        self.transport.loseConnection()
        return

    # Implement rate limiting
    if not self.factory.allow_connection(self.ip):
        logger.info(f"Rate limit exceeded for IP: {self.ip}")
        self.transport.loseConnection()
        return

    banner = self._get_banner()
    logger.info(f"Connection from {self.ip}")
    self.sendLine(banner.encode('utf-8'))
    log_interaction(self.ip, 'WELCOME', banner)

default_responses()

Provide default SMTP responses in case AI-generated responses are unavailable.

Returns:

Name Type Description
dict

A dictionary of default SMTP responses.

Source code in src/smtp_protocol.py
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
def default_responses(self):
    """
    Provide default SMTP responses in case AI-generated responses are unavailable.

    Returns:
        dict: A dictionary of default SMTP responses.
    """
    return {
        "220": f"220 {domain_name} ESMTP",
        "221": f"221 {domain_name} Service closing transmission channel",
        "235": "235 Authentication successful",
        "250": f"250 {domain_name}",
        "250-EHLO": f"250-{domain_name} Hello [{self.ip}]",
        "250-HELO": f"250 {domain_name}",
        "250-MAIL FROM": "250 2.1.0 Sender OK",
        "250-RCPT TO": "250 2.1.5 Recipient OK",
        "250-DATA": "250 OK: Queued",
        "252": "252 Cannot VRFY user, but will accept message and attempt delivery",
        "502": "502 Command not implemented",
        "214": "214 Help message",
        "354": "354 End data with <CR><LF>.<CR><LF>",
        "500": "500 Command unrecognized",
        "535": "535 Authentication failed",
    }

lineReceived(line)

Handle received lines of data, processing SMTP commands and managing state transitions.

Parameters:

Name Type Description Default
line bytes

The line of data received.

required
Source code in src/smtp_protocol.py
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
def lineReceived(self, line):
    """
    Handle received lines of data, processing SMTP commands and managing state transitions.

    Args:
        line (bytes): The line of data received.
    """
    try:
        try:
            command = line.decode('utf-8').strip()
        except UnicodeDecodeError:
            command = line.decode('latin-1').strip()
        logger.info(f"Received command from {self.ip}: {command}")

        if self.state == 'DATA':
            if command == ".":
                self.state = 'INITIAL'
                data_message = "\n".join(self.data_buffer)
                logger.info(f"Received DATA from {self.ip}:\n{data_message}")
                self.data_buffer = []
                response = self.responses.get("250-DATA", "250 OK: Queued")
            else:
                self.data_buffer.append(command)
                return
        elif self.auth_step:
            response = self._handle_auth(command)
            if response == '':
                return  # Wait for next authentication input
        else:
            response = self._get_response(command)
            if response == '':
                return  # Already handled in _get_response

        if command_upper := command.upper():
            if command_upper.startswith("QUIT"):
                self.sendLine(response.encode('utf-8'))
                log_interaction(self.ip, command, response)
                self.transport.loseConnection()
                return

        self.sendLine(response.encode('utf-8'))
        log_interaction(self.ip, command, response)
    except Exception as e:
        logger.error(f"Error processing command from {self.ip}: {e}")
        self.sendLine(b"500 Command unrecognized")

reset_state()

Reset the protocol state to INITIAL, clearing any buffers or authentication steps.

Source code in src/smtp_protocol.py
288
289
290
291
292
293
294
295
296
def reset_state(self):
    """
    Reset the protocol state to INITIAL, clearing any buffers or authentication steps.
    """
    self.state = 'INITIAL'
    self.data_buffer = []
    self.auth_step = None
    self.auth_username = None
    self.auth_password = None

SMTPFactory Class

The SMTPFactory class is a factory for creating instances of the SMTPProtocol class. It initializes and builds new protocol instances for handling connections.

smtp_protocol.SMTPFactory

Bases: Factory

A factory class for creating instances of SMTPProtocol.

Source code in src/smtp_protocol.py
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
class SMTPFactory(protocol.Factory):
    """
    A factory class for creating instances of SMTPProtocol.
    """

    def __init__(self, debug=False):
        """
        Initialize the SMTPFactory.

        Args:
            debug (bool): Indicates whether debug mode is enabled.
        """
        self.debug = debug or debug_mode
        if self.debug:
            logger.setLevel(logging.DEBUG)
        logger.debug("SMTPFactory initialized")
        self.blacklist = blacklist
        self.rate_limit = rate_limit
        self.connection_attempts = {}  # Dictionary to track connection times per IP

    def buildProtocol(self, addr):
        """
        Build and return an instance of SMTPProtocol.

        Args:
            addr (Address): The address of the incoming connection.

        Returns:
            SMTPProtocol: A new instance of SMTPProtocol.
        """
        logger.debug(f"Building SMTP protocol for {addr.host}")
        return SMTPProtocol(self, debug=self.debug)

    def allow_connection(self, ip):
        """
        Determine if a connection from the given IP should be allowed based on rate limiting.

        Args:
            ip (str): The IP address of the client.

        Returns:
            bool: True if the connection is allowed, False otherwise.
        """
        current_time = time.time()
        attempts = self.connection_attempts.get(ip, [])

        # Remove attempts older than 1 minute
        attempts = [t for t in attempts if current_time - t < 60]

        # Update the attempts list
        attempts.append(current_time)
        self.connection_attempts[ip] = attempts

        if self.debug:
            logger.debug(f"Connection attempts from {ip}: {len(attempts)}")

        # Check if attempts exceed rate limit
        if len(attempts) > self.rate_limit:
            # Schedule unblocking after 1 minute
            reactor.callLater(60, self._unblock_ip, ip)
            return False
        return True

    def _unblock_ip(self, ip):
        """
        Unblock the IP by resetting its connection attempts.

        Args:
            ip (str): The IP address to unblock.
        """
        if ip in self.connection_attempts:
            del self.connection_attempts[ip]
            if self.debug:
                logger.debug(f"IP {ip} has been unblocked after rate limiting period.")

__init__(debug=False)

Initialize the SMTPFactory.

Parameters:

Name Type Description Default
debug bool

Indicates whether debug mode is enabled.

False
Source code in src/smtp_protocol.py
378
379
380
381
382
383
384
385
386
387
388
389
390
391
def __init__(self, debug=False):
    """
    Initialize the SMTPFactory.

    Args:
        debug (bool): Indicates whether debug mode is enabled.
    """
    self.debug = debug or debug_mode
    if self.debug:
        logger.setLevel(logging.DEBUG)
    logger.debug("SMTPFactory initialized")
    self.blacklist = blacklist
    self.rate_limit = rate_limit
    self.connection_attempts = {}  # Dictionary to track connection times per IP

allow_connection(ip)

Determine if a connection from the given IP should be allowed based on rate limiting.

Parameters:

Name Type Description Default
ip str

The IP address of the client.

required

Returns:

Name Type Description
bool

True if the connection is allowed, False otherwise.

Source code in src/smtp_protocol.py
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
def allow_connection(self, ip):
    """
    Determine if a connection from the given IP should be allowed based on rate limiting.

    Args:
        ip (str): The IP address of the client.

    Returns:
        bool: True if the connection is allowed, False otherwise.
    """
    current_time = time.time()
    attempts = self.connection_attempts.get(ip, [])

    # Remove attempts older than 1 minute
    attempts = [t for t in attempts if current_time - t < 60]

    # Update the attempts list
    attempts.append(current_time)
    self.connection_attempts[ip] = attempts

    if self.debug:
        logger.debug(f"Connection attempts from {ip}: {len(attempts)}")

    # Check if attempts exceed rate limit
    if len(attempts) > self.rate_limit:
        # Schedule unblocking after 1 minute
        reactor.callLater(60, self._unblock_ip, ip)
        return False
    return True

buildProtocol(addr)

Build and return an instance of SMTPProtocol.

Parameters:

Name Type Description Default
addr Address

The address of the incoming connection.

required

Returns:

Name Type Description
SMTPProtocol

A new instance of SMTPProtocol.

Source code in src/smtp_protocol.py
393
394
395
396
397
398
399
400
401
402
403
404
def buildProtocol(self, addr):
    """
    Build and return an instance of SMTPProtocol.

    Args:
        addr (Address): The address of the incoming connection.

    Returns:
        SMTPProtocol: A new instance of SMTPProtocol.
    """
    logger.debug(f"Building SMTP protocol for {addr.host}")
    return SMTPProtocol(self, debug=self.debug)