Skip to content

SMTP module: Fix response code and Cyrillic character handling issues #277

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/docker-dev-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
push: true
build-args: |
APP_VERSION=${{ steps.previoustag.outputs.tag }}
FRONTEND_IMAGE_TAG=dev
FRONTEND_IMAGE_TAG=latest
BRANCH=${{ steps.previoustag.outputs.tag }}
tags:
ghcr.io/${{ github.repository }}:dev, ghcr.io/${{ github.repository }}:${{ steps.previoustag.outputs.tag }}
2 changes: 1 addition & 1 deletion .github/workflows/phpunit-mysql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ jobs:
run: echo "::set-output name=dir::$(composer config cache-files-dir)"

- name: Restore Composer Cache
uses: actions/cache@v2
uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/phpunit-pgsql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ jobs:
run: echo "::set-output name=dir::$(composer config cache-files-dir)"

- name: Restore Composer Cache
uses: actions/cache@v2
uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }}
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ protoc-gen-php-grpc*
.db
.sqlhistory
*Zone.Identifier
.context
2 changes: 1 addition & 1 deletion .php-cs-fixer.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@
(new PhpCsFixer\Finder())
->files()
->name('*.php')
->in([__DIR__ . '/app/src', __DIR__ . '/app/modules']),
->in([__DIR__ . '/app/src', __DIR__ . '/app/modules', __DIR__ . '/tests']),
)
->setCacheFile('.cache/.php-cs-fixer.cache');
3 changes: 2 additions & 1 deletion app/modules/Smtp/Application/Mail/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ public function getBccs(): array
return \array_values(
\array_filter($this->allRecipients, function (string $recipient) {
foreach (\array_merge($this->recipients, $this->ccs) as $publicRecipient) {
if (\str_contains($publicRecipient, $recipient)) {
// Use email from the public recipient array
if (isset($publicRecipient['email']) && \str_contains($publicRecipient['email'], $recipient)) {
return false;
}
}
Expand Down
21 changes: 15 additions & 6 deletions app/modules/Smtp/Application/Storage/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@

namespace Modules\Smtp\Application\Storage;

use Modules\Smtp\Application\Mail\Parser;

final class Message
{
public function __construct(
Expand Down Expand Up @@ -45,21 +43,32 @@ public function setFrom(string $from): void

public function appendBody(string $body): void
{
$this->body .= \preg_replace("/^(\.\.)/m", '.', $body);
// Handle escaped periods at the beginning of lines per SMTP spec
$safeBody = \preg_replace("/^(\.\.)/m", '.', $body);

// Ensure body is properly appended even with multi-byte characters
$this->body .= $safeBody;
}

public function bodyHasEos(): bool
{
return \str_ends_with($this->body, "\r\n.\r\n");
// More robust check for end of stream marker
// This handles potential encoding issues with multi-byte characters
return \mb_substr($this->body, -5) === "\r\n.\r\n";
}

public function getBody(): string
{
return \str_replace("\r\n.\r\n", '', $this->body);
// Remove the end of stream marker in a way that's safe for multi-byte strings
if ($this->bodyHasEos()) {
return \mb_substr($this->body, 0, \mb_strlen($this->body) - 5);
}

return $this->body;
}

public function parse(): \Modules\Smtp\Application\Mail\Message
{
return (new Parser())->parse($this->getBody());
return ParserFactory::getInstance()->create()->parse($this->getBody(), $this->recipients);
}
}
63 changes: 63 additions & 0 deletions app/modules/Smtp/Application/Storage/ParserFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

namespace Modules\Smtp\Application\Storage;

use Modules\Smtp\Application\Mail\Parser;

/**
* Factory for creating Parser instances
* This class exists primarily to facilitate testing by allowing
* the Parser instantiation to be mocked or overridden.
*
* @internal
*/
final class ParserFactory
{
// Static instance for singleton pattern
private static ?self $instance = null;

// The Parser instance or null
private ?Parser $parser = null;

/**
* Get the factory instance
*/
public static function getInstance(): self
{
if (!self::$instance instanceof \Modules\Smtp\Application\Storage\ParserFactory) {
self::$instance = new self();
}

return self::$instance;
}

/**
* Reset the factory instance (useful for testing)
*/
public static function reset(): void
{
self::$instance = null;
}

/**
* Set a custom Parser instance
*
* @param Parser $parser The Parser instance to use
*/
public function setParser(Parser $parser): void
{
$this->parser = $parser;
}

/**
* Create a Parser instance
*
* @return Parser The Parser instance
*/
public function create(): Parser
{
return $this->parser ?? new Parser();
}
}
12 changes: 9 additions & 3 deletions app/modules/Smtp/Interfaces/TCP/Service.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ public function handle(Request $request): ResponseInterface
$response = $this->makeResponse(ResponseMessage::closing(), close: true);
$message = $this->emailBodyStorage->cleanup($request->connectionUuid);
} elseif ($request->body === "DATA\r\n") {
// Reset the body to empty string when starting a new DATA command
// This prevents confusion between multiple DATA commands in the same session
$message->body = '';
$response = $this->makeResponse(ResponseMessage::provideBody());
$message->waitBody = true;
} elseif ($request->body === "RSET\r\n") {
Expand All @@ -75,13 +78,16 @@ public function handle(Request $request): ResponseInterface
} elseif ($message->waitBody) {
$message->appendBody($request->body);

$response = $this->makeResponse(ResponseMessage::ok());

// FIX: Only send one response when data ends
if ($message->bodyHasEos()) {
$uuid = $this->dispatchMessage($message->parse(), project: $message->username);

$response = $this->makeResponse(ResponseMessage::accepted($uuid));
$dispatched = true;
// Reset the waitBody flag to false since we've processed the message
$message->waitBody = false;
} else {
// Only send "OK" response if we're not at the end of data
$response = $this->makeResponse(ResponseMessage::ok());
}
}

Expand Down
17 changes: 17 additions & 0 deletions app/modules/context.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json'

documents:
- description: Events module
outputPath: module/events.md
sources:
- type: file
sourcePaths:
- ./Events

- description: SMTP module
outputPath: module/smtp.md
sources:
- type: file
sourcePaths:
- ./Smtp
- ../../docs/smtp.md
15 changes: 15 additions & 0 deletions context.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json'

import:
- path: app/**/context.yaml
- type: url
url: https://gist.githubusercontent.com/butschster/29e84fb9c976ac837181141f88049a35/raw/e869d8dfc210c70ae6e31278b1322b98e1e575cb/dev-prompts.yaml

documents:
- description: 'Project structure overview'
outputPath: project-structure.md
sources:
- type: tree
sourcePaths:
- app
showCharCount: true
134 changes: 134 additions & 0 deletions docs/smtp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# SMTP Module

## Purpose of the Component

The SMTP module provides email handling functionality, allowing the application to receive, parse, store, and retrieve
emails with attachments. It handles the full lifecycle of email messages from SMTP protocol interaction to storage and
retrieval of email content and attachments.

## Detailed Description for Business Analyst and Marketing

The SMTP module serves as an email receiver and processor within the application, enabling automated email capture and
storage. It solves the business problem of needing to automatically process incoming emails in a structured way, making
them available through the application's API.

This component adds significant value by:

- Capturing email communications automatically without manual intervention
- Storing emails systematically for future reference and processing
- Making email content and attachments available through a standardized API
- Supporting business workflows that are triggered by or depend on email communication

The module integrates with the rest of the application to ensure emails become first-class entities within the system,
enabling email-driven processes and workflows.

## Mermaid Sequence Diagram for Business Analyst and Marketing

```mermaid
sequenceDiagram
participant EmailClient as Email Client
participant SMTPService as SMTP Service
participant Parser as Email Parser
participant Storage as Storage System
participant API as Application API
participant Client as API Client
EmailClient ->> SMTPService: Send email
SMTPService ->> Parser: Pass raw email
Parser ->> Parser: Parse email content
Parser ->> Storage: Store email body
Parser ->> Storage: Store attachments
SMTPService ->> SMTPService: Generate event UUID
SMTPService ->> API: Dispatch email received event
Note over SMTPService, API: Email is now available in the system
Client ->> API: Request email content
API ->> Storage: Retrieve email
Storage ->> API: Return email data
API ->> Client: Send email content
Client ->> API: Request attachment
API ->> Storage: Retrieve attachment
Storage ->> API: Return attachment data
API ->> Client: Send attachment
```

## List of Actors

1. **Email Sender** - Any system or person sending emails to the application's SMTP server.
2. **API Client** - Applications or services that consume the email data through the HTTP API.
3. **Event Listeners** - Internal system components that react to email-related events.
4. **Storage System** - Handles persistence of email bodies and attachments.
5. **Event System** - Distributes events related to emails throughout the application.

## List of Business Rules

### SMTP Protocol Rules

- The system must support standard SMTP commands (HELO/EHLO, MAIL FROM, RCPT TO, DATA, etc.)
- Authentication is required for sending emails (AUTH LOGIN supported)
- Multiple recipients (TO, CC, BCC) must be properly tracked

### Email Processing Rules

- All emails must be assigned a unique UUID for tracking
- Both HTML and plain text email bodies must be preserved
- Email metadata (sender, recipients, subject) must be extracted and stored
- All attachments must be properly stored with correct MIME types

### Attachment Handling Rules

- Attachments must be stored securely with access control
- Attachments must be accessible via HTTP API only by authorized clients
- Inline attachments (content-id) must be properly linked in HTML content
- Each attachment must have a unique UUID independent of its filename

### Data Retention Rules

- Email bodies are temporarily cached (1 minute) during the SMTP session
- Attachments are persisted until the associated event is deleted

## Domain Ubiquitous Terminology

- **SMTP** - Simple Mail Transfer Protocol, the standard protocol for email transmission
- **Attachment** - A file attached to an email message
- **Content-ID** - A unique identifier for inline attachments referenced in HTML email content
- **Event** - A record of an action that occurred in the system, in this case receiving an email
- **MIME Type** - The format identifier for a file (e.g., image/jpeg, application/pdf)
- **Parser** - Component that extracts structured data from raw email content
- **Repository** - A storage abstraction for accessing and managing domain objects

## Simple Use Cases

### Use Case 1: Receiving a Customer Support Email

1. A customer sends an email to [email protected]
2. The SMTP service receives the email and authenticates the session
3. The email content and any attachments are parsed and stored
4. The system generates a unique event for this email
5. The support team is notified of the new support request
6. Support staff can view the email content and download attachments via the application

### Use Case 2: Automated Document Processing

1. A business partner sends documents as email attachments
2. The SMTP module receives the email and processes it
3. Attachments are stored with appropriate metadata
4. Another system component is notified of new documents
5. The documents are automatically processed according to business rules
6. Results of processing are made available through the API

### Use Case 3: Email-Triggered Workflow

1. A specific email address receives status updates from an external system
2. The SMTP module captures these emails
3. The system parses the email subject and body for status information
4. Based on the content, specific workflow actions are triggered
5. The email and its metadata remain available for audit purposes

## Known Limitations / TODO

- The module currently doesn't support outbound email sending
- No support for email encryption (PGP, S/MIME)
- Limited email thread tracking functionality
- Potential improvement: Add full-text search for email content
- Consideration: Implement IMAP support for accessing remote mailboxes
- TODO: Enhance performance for handling large attachments
- TODO: Add spam filtering capabilities
9 changes: 4 additions & 5 deletions tests/App/Broadcasting/BroadcastFaker.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@
{
public function __construct(
private Container $container,
) {
}
) {}

public function dump(): self
{
Expand All @@ -31,7 +30,7 @@ public function reset(): self

public function assertPushedTimes(string|\Stringable $topic, int $times = 1): array
{
$messages = $this->filterMessages((string)$topic);
$messages = $this->filterMessages((string) $topic);

TestCase::assertCount(
$times,
Expand All @@ -50,7 +49,7 @@ public function assertPushedTimes(string|\Stringable $topic, int $times = 1): ar

public function assertPushed(string|\Stringable $topic, \Closure $callback = null): self
{
$messages = $this->filterMessages((string)$topic, $callback);
$messages = $this->filterMessages((string) $topic, $callback);

TestCase::assertTrue(
$messages !== [],
Expand All @@ -62,7 +61,7 @@ public function assertPushed(string|\Stringable $topic, \Closure $callback = nul

public function assertNotPushed(string|\Stringable $topic, \Closure $callback = null): self
{
$messages = $this->filterMessages((string)$topic, $callback);
$messages = $this->filterMessages((string) $topic, $callback);

TestCase::assertCount(
0,
Expand Down
Loading
Loading