Back to Repositories

Validating SMTP Client Message Completion Workflow in Postal

This test suite validates the SMTP server client’s data handling functionality in Postal, focusing on message completion scenarios and various email routing paths. It ensures proper handling of incoming, outgoing, and bounce messages with appropriate validation checks.

Test Coverage Overview

The test suite provides comprehensive coverage of SMTP client data handling scenarios.

Key areas tested include:
  • Message size validation
  • Loop detection in email routing
  • Credential validation
  • Outgoing email processing
  • Bounce message handling
  • Incoming email routing
Edge cases include malformed data endings, oversized messages, and invalid credentials.

Implementation Analysis

The testing approach uses RSpec’s describe/context/it blocks to organize test scenarios hierarchically. It leverages factory-created test data and extensive setup mocking.

Key patterns include:
  • Shared setup using before blocks
  • Let declarations for test data
  • Subject-based testing
  • Context-specific credential handling
  • Expectation matching for message attributes

Technical Details

Testing tools and configuration:
  • RSpec as the testing framework
  • Factory-based test data generation
  • Rails test helpers integration
  • SMTP protocol simulation
  • Message queuing system validation
  • Configuration mocking for SMTP settings

Best Practices Demonstrated

The test suite exemplifies high-quality testing practices through organized, maintainable code structure.

Notable practices include:
  • Comprehensive setup and teardown
  • Isolated test scenarios
  • Clear context separation
  • Thorough attribute validation
  • Proper error handling coverage
  • Modular test organization

postalserver/postal

spec/lib/smtp_server/client/finished_spec.rb

            
# frozen_string_literal: true

require "rails_helper"

module SMTPServer

  describe Client do
    let(:ip_address) { "1.2.3.4" }
    let(:server) { create(:server) }
    subject(:client) { described_class.new(ip_address) }

    let(:credential) { create(:credential, server: server, type: "SMTP") }
    let(:auth_plain) { credential&.to_smtp_plain }
    let(:mail_from) { "[email protected]" }
    let(:rcpt_to) { "[email protected]" }

    before do
      client.handle("HELO test.example.com")
      client.handle("AUTH PLAIN #{auth_plain}") if auth_plain
      client.handle("MAIL FROM: #{mail_from}")
      client.handle("RCPT TO: #{rcpt_to}")
    end

    describe "when finished sending data" do
      context "when the . character does not end with a <CR>" do
        it "does nothing" do
          allow(Postal::Config.smtp_server).to receive(:max_message_size).and_return(1)
          client.handle("DATA")
          client.handle("Subject: Hello")
          client.handle("\r")
          expect(client.handle(".")).to be nil
        end
      end

      context "when the data before the . character does not end with a <CR>" do
        it "does nothing" do
          allow(Postal::Config.smtp_server).to receive(:max_message_size).and_return(1)
          client.handle("DATA")
          client.handle("Subject: Hello")
          expect(client.handle(".\r")).to be nil
        end
      end

      context "when the data is larger than the maximum message size" do
        it "returns an error and resets the state" do
          allow(Postal::Config.smtp_server).to receive(:max_message_size).and_return(1)
          client.handle("DATA")
          client.handle("a" * 1024 * 1024 * 10)
          client.handle("\r")
          expect(client.handle(".\r")).to eq "552 Message too large (maximum size 1MB)"
        end
      end

      context "when a loop is detected" do
        it "returns an error and resets the state" do
          client.handle("DATA")
          client.handle("Received: from example1.com by #{Postal::Config.postal.smtp_hostname}")
          client.handle("Received: from example2.com by #{Postal::Config.postal.smtp_hostname}")
          client.handle("Received: from example1.com by #{Postal::Config.postal.smtp_hostname}")
          client.handle("Received: from example2.com by #{Postal::Config.postal.smtp_hostname}")
          client.handle("Subject: Test")
          client.handle("From: #{mail_from}")
          client.handle("To: #{rcpt_to}")
          client.handle("")
          client.handle("This is a test message")
          client.handle("\r")
          expect(client.handle(".\r")).to eq "550 Loop detected"
        end
      end

      context "when the email content is not suitable for the credential" do
        it "returns an error and resets the state" do
          client.handle("DATA")
          client.handle("Subject: Test")
          client.handle("From: [email protected]")
          client.handle("To: #{rcpt_to}")
          client.handle("")
          client.handle("This is a test message")
          client.handle("\r")
          expect(client.handle(".\r")).to eq "530 From/Sender name is not valid"
        end
      end

      context "when sending an outgoing email" do
        let(:domain) { create(:domain, owner: server) }
        let(:mail_from) { "test@#{domain.name}" }
        let(:auth_plain) { credential.to_smtp_plain }

        it "stores the message and resets the state" do
          client.handle("DATA")
          client.handle("Subject: Test")
          client.handle("From: #{mail_from}")
          client.handle("To: #{rcpt_to}")
          client.handle("")
          client.handle("This is a test message")
          client.handle("\r")
          expect(client.handle(".\r")).to eq "250 OK"
          queued_message = QueuedMessage.first
          expect(queued_message).to have_attributes(
            domain: "example.com",
            server: server
          )

          expect(server.message(queued_message.message_id)).to have_attributes(
            mail_from: mail_from,
            rcpt_to: rcpt_to,
            subject: "Test",
            scope: "outgoing",
            route_id: nil,
            credential_id: credential.id,
            raw_headers: kind_of(String),
            raw_message: kind_of(String)
          )
        end
      end

      context "when sending a bounce message" do
        let(:credential) { nil }
        let(:rcpt_to) { "#{server.token}@#{Postal::Config.dns.return_path_domain}" }

        context "when there is a return path route" do
          let(:domain) { create(:domain, owner: server) }

          before do
            endpoint = create(:http_endpoint, server: server)
            create(:route, domain: domain, server: server, name: "__returnpath__", mode: "Endpoint", endpoint: endpoint)
          end

          it "stores the message for the return path route and resets the state" do
            client.handle("DATA")
            client.handle("Subject: Bounce: Test")
            client.handle("From: #{mail_from}")
            client.handle("To: #{rcpt_to}")
            client.handle("")
            client.handle("This is a test message")
            client.handle("\r")
            expect(client.handle(".\r")).to eq "250 OK"

            queued_message = QueuedMessage.first
            expect(queued_message).to have_attributes(
              domain: Postal::Config.dns.return_path_domain,
              server: server
            )

            expect(server.message(queued_message.message_id)).to have_attributes(
              mail_from: mail_from,
              rcpt_to: rcpt_to,
              subject: "Bounce: Test",
              scope: "incoming",
              route_id: server.routes.first.id,
              domain_id: domain.id,
              credential_id: nil,
              raw_headers: kind_of(String),
              raw_message: kind_of(String),
              bounce: true
            )
          end
        end

        context "when there is no return path route" do
          it "stores the message normally and resets the state" do
            client.handle("DATA")
            client.handle("Subject: Bounce: Test")
            client.handle("From: #{mail_from}")
            client.handle("To: #{rcpt_to}")
            client.handle("")
            client.handle("This is a test message")
            client.handle("\r")
            expect(client.handle(".\r")).to eq "250 OK"

            queued_message = QueuedMessage.first
            expect(queued_message).to have_attributes(
              domain: Postal::Config.dns.return_path_domain,
              server: server
            )

            expect(server.message(queued_message.message_id)).to have_attributes(
              mail_from: mail_from,
              rcpt_to: rcpt_to,
              subject: "Bounce: Test",
              scope: "incoming",
              route_id: nil,
              domain_id: nil,
              credential_id: nil,
              raw_headers: kind_of(String),
              raw_message: kind_of(String),
              bounce: true
            )
          end
        end
      end

      context "when receiving an incoming email" do
        let(:domain) { create(:domain, owner: server) }
        let(:route) { create(:route, server: server, domain: domain) }

        let(:credential) { nil }
        let(:rcpt_to) { "#{route.name}@#{domain.name}" }

        it "stores the message and resets the state" do
          client.handle("DATA")
          client.handle("Subject: Test")
          client.handle("From: #{mail_from}")
          client.handle("To: #{rcpt_to}")
          client.handle("")
          client.handle("This is a test message")
          client.handle("\r")
          expect(client.handle(".\r")).to eq "250 OK"

          queued_message = QueuedMessage.first
          expect(queued_message).to have_attributes(
            domain: domain.name,
            server: server
          )

          expect(server.message(queued_message.message_id)).to have_attributes(
            mail_from: mail_from,
            rcpt_to: rcpt_to,
            subject: "Test",
            scope: "incoming",
            route_id: route.id,
            domain_id: domain.id,
            credential_id: nil,
            raw_headers: kind_of(String),
            raw_message: kind_of(String)
          )
        end
      end
    end
  end

end