Unify file storage under a single documents table
Replaces a polymorphic join with a uniform documents table; emails
are documents too.
Assisted-by: Claude Sonnet 4.6 via Claude Code
diff --git a/config.ru b/config.ru
index e3b802c..12a9f62 100644
--- a/config.ru
+++ b/config.ru
@@ -7,4 +7,5 @@ require "sequel/extensions/migration"
app = Domus::App.new
Sequel::Migrator.run(app.db, "db/migrate") unless Dir.empty?("db/migrate")
Domus::Web.opts[:app] = app
+
run Domus::Web
diff --git a/db/migrate/001_create_emails_and_attachments.rb b/db/migrate/001_create_emails_and_attachments.rb
new file mode 100644
index 0000000..acd26fc
--- /dev/null
+++ b/db/migrate/001_create_emails_and_attachments.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+ change do
+ create_table(:documents) do
+ primary_key :id
+ String :path, null: false
+ String :kind, null: false # "email", "pdf", "image", "unknown"
+ DateTime :received_at, null: false
+ DateTime :created_at, null: false
+ end
+
+ create_table(:email_attachments) do
+ primary_key :id
+ foreign_key :email_id, :documents, null: false, index: true
+ foreign_key :document_id, :documents, null: false, index: true
+ String :filename, null: false
+ DateTime :created_at, null: false
+ end
+
+ run <<~SQL
+ CREATE TRIGGER enforce_email_attachment_kind
+ BEFORE INSERT ON email_attachments
+ BEGIN
+ SELECT RAISE(ABORT, 'email_id must reference a document with kind=''email''')
+ WHERE (SELECT kind FROM documents WHERE id = NEW.email_id) != 'email';
+ END;
+ SQL
+ end
+end
diff --git a/docs/plans/2026-06-02-documents-schema.md b/docs/plans/2026-06-02-documents-schema.md
new file mode 100644
index 0000000..1b6e928
--- /dev/null
+++ b/docs/plans/2026-06-02-documents-schema.md
@@ -0,0 +1,457 @@
+# Documents Schema Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Replace the separate `emails`, `pdfs`, `images`, and polymorphic `attachments` tables with a unified `documents` table and a clean `email_attachments` join table.
+
+**Architecture:** Every stored file (email, PDF, image, unknown) is a `documents` row with a `kind` column. The relationship "this document was attached to this email" is modeled as an `email_attachments` row with two foreign keys into `documents`. No polymorphic joins.
+
+**Tech Stack:** Ruby, Sequel, SQLite, Minitest, Rack::Test
+
+---
+
+### Task 1: Rewrite the migration
+
+Since migration 001 is staged but not yet committed, rewrite it in place. The new schema drops the four old tables and creates two.
+
+**Files:**
+- Modify: `db/migrate/001_create_emails_and_attachments.rb`
+
+**Step 1: Replace the migration**
+
+```ruby
+# frozen_string_literal: true
+
+Sequel.migration do
+ change do
+ create_table(:documents) do
+ primary_key :id
+ String :path, null: false
+ String :kind, null: false # "email", "pdf", "image", "unknown"
+ DateTime :received_at, null: false
+ DateTime :created_at, null: false
+ end
+
+ create_table(:email_attachments) do
+ primary_key :id
+ foreign_key :email_id, :documents, null: false
+ foreign_key :document_id, :documents, null: false
+ String :filename, null: false
+ DateTime :created_at, null: false
+ end
+ end
+end
+```
+
+**Step 2: Verify it loads without error**
+
+```
+bundle exec ruby -e "require 'sequel'; require_relative 'db/migrate/001_create_emails_and_attachments'"
+```
+
+Expected: no output, exit 0.
+
+---
+
+### Task 2: Rewrite the ingestion tests
+
+Write tests that target the new schema. They will fail until Task 3 is done.
+
+**Files:**
+- Modify: `test/test_email_ingestion.rb`
+
+**Step 1: Replace the test file**
+
+```ruby
+# frozen_string_literal: true
+
+require_relative "test_helper"
+require "rack/test"
+require "tempfile"
+require "fileutils"
+require "sequel"
+require_relative "../lib/email_ingestion"
+require_relative "../lib/config"
+
+class TestEmailIngestion < Minitest::Test
+ include Rack::Test::Methods
+
+ def app
+ Domus::Web
+ end
+
+ def setup
+ @test_storage = Dir.mktmpdir("domus-email-test")
+ @config = Domus::Config.new(database_url: ":memory:", storage_path: @test_storage)
+ @app = Domus::App.new(@config)
+ Sequel::Migrator.run(@app.db, migrate_dir)
+
+ @old_app = Domus::Web.opts[:app]
+ Domus::Web.opts[:app] = @app
+ end
+
+ def teardown
+ Domus::Web.opts[:app] = @old_app
+ FileUtils.rm_rf(@test_storage)
+ end
+
+ def test_rejects_missing_email_field
+ post "/inbound/email"
+ assert_equal 400, last_response.status
+ end
+
+ def test_accepts_email_without_attachments
+ post "/inbound/email", email: sample_email
+ assert_equal 200, last_response.status
+
+ email_doc = @app.db[:documents].where(kind: "email").first
+ refute_nil email_doc
+ assert_match %r{emails/\d{4}-\d{2}-\d{2}-.+\.eml}, email_doc[:path]
+ end
+
+ def test_stores_eml_file
+ post "/inbound/email", email: sample_email
+ assert_equal 200, last_response.status
+
+ email_doc = @app.db[:documents].where(kind: "email").first
+ full_path = File.join(@test_storage, email_doc[:path])
+ assert File.exist?(full_path)
+ assert_equal sample_email, File.read(full_path)
+ end
+
+ def test_stores_pdf_attachment
+ post "/inbound/email",
+ email: sample_email,
+ attachment1: attachment_param("report.pdf", "application/pdf", "pdf content")
+
+ assert_equal 200, last_response.status
+
+ ea = @app.db[:email_attachments].first
+ refute_nil ea
+ assert_match(/report\.pdf/, ea[:filename])
+
+ email_doc = @app.db[:documents].where(kind: "email").first
+ assert_equal email_doc[:id], ea[:email_id]
+
+ att_doc = @app.db[:documents].where(id: ea[:document_id]).first
+ full_path = File.join(@test_storage, att_doc[:path])
+ assert File.exist?(full_path)
+ assert_equal "pdf content", File.read(full_path)
+ end
+
+ def test_stores_multiple_attachments
+ post "/inbound/email",
+ email: sample_email,
+ attachment1: attachment_param("a.txt", "text/plain", "aaa"),
+ attachment2: attachment_param("b.txt", "text/plain", "bbb")
+
+ assert_equal 200, last_response.status
+ assert_equal 2, @app.db[:email_attachments].count
+ assert_equal 3, @app.db[:documents].count
+ end
+
+ def test_attachment_linked_to_correct_email
+ post "/inbound/email",
+ email: sample_email,
+ attachment1: attachment_param("doc.pdf", "application/pdf", "data")
+
+ email_doc = @app.db[:documents].where(kind: "email").first
+ ea = @app.db[:email_attachments].first
+ assert_equal email_doc[:id], ea[:email_id]
+ end
+
+ def test_pdf_attachment_has_correct_kind
+ post "/inbound/email",
+ email: sample_email,
+ attachment1: attachment_param("file.pdf", "application/pdf", "data")
+
+ ea = @app.db[:email_attachments].first
+ doc = @app.db[:documents].where(id: ea[:document_id]).first
+ assert_equal "pdf", doc[:kind]
+ end
+
+ def test_attachment_path_includes_random_uuid
+ post "/inbound/email",
+ email: sample_email,
+ attachment1: attachment_param("file.pdf", "application/pdf", "data")
+
+ ea = @app.db[:email_attachments].first
+ doc = @app.db[:documents].where(id: ea[:document_id]).first
+ assert_match %r{pdfs/\d{4}-\d{2}-\d{2}-.+file\.pdf}, doc[:path]
+ end
+
+ private
+
+ def migrate_dir
+ File.expand_path("../db/migrate", __dir__)
+ end
+
+ def sample_email
+ "From: sender@example.com\nSubject: Test Subject\n\nBody"
+ end
+
+ def attachment_param(filename, type, content)
+ tempfile = Tempfile.new(filename)
+ tempfile.write(content)
+ tempfile.rewind
+ Rack::Test::UploadedFile.new(tempfile.path, type)
+ end
+end
+```
+
+**Step 2: Run tests to confirm they fail**
+
+```
+bundle exec ruby -Itest test/test_email_ingestion.rb
+```
+
+Expected: failures referencing missing `documents` table or wrong column names (since `email_ingestion.rb` still writes to old tables).
+
+---
+
+### Task 3: Rewrite the ingestion processor
+
+Replace the multi-table dispatch with a single `documents` + `email_attachments` write path.
+
+**Files:**
+- Modify: `lib/email_ingestion.rb`
+
+**Step 1: Replace the file**
+
+```ruby
+# frozen_string_literal: true
+
+require "securerandom"
+require "fileutils"
+
+module Domus
+ module EmailIngestation
+ class Processor
+ def initialize(app)
+ @app = app
+ @config = app.config
+ @db = app.db
+ @storage = config.storage_path
+ end
+
+ def call(email_field:, attachments: [])
+ eml_path = nil
+ written_files = []
+
+ db.transaction do
+ eml_relative = write_eml(email_field)
+ eml_path = eml_relative
+ written_files << eml_relative
+
+ email_doc_id = db[:documents].insert(
+ path: eml_relative,
+ kind: "email",
+ received_at: Time.now,
+ created_at: Time.now
+ )
+
+ attachments.each do |att|
+ kind = classify(att[:filename])
+ content = att[:tempfile].read
+ relative = write_file(storage_subdir(kind), att[:filename], content)
+ written_files << relative
+
+ doc_id = db[:documents].insert(
+ path: relative,
+ kind: kind.to_s,
+ received_at: Time.now,
+ created_at: Time.now
+ )
+
+ db[:email_attachments].insert(
+ email_id: email_doc_id,
+ document_id: doc_id,
+ filename: att[:filename],
+ created_at: Time.now
+ )
+ end
+ end
+
+ { email_path: eml_path }
+ ensure
+ written_files.each { |rel| FileUtils.rm_f(File.join(storage, rel)) } if $!
+ end
+
+ private
+
+ attr_reader :config, :db
+
+ def classify(filename)
+ ext = File.extname(filename).downcase
+ return :pdf if ext == ".pdf"
+ return :image if %w[.jpg .jpeg .png .gif .webp .bmp .tiff .tif .svg].include?(ext)
+
+ :unknown
+ end
+
+ def storage_subdir(kind)
+ case kind
+ when :pdf then "pdfs"
+ when :image then "images"
+ else "files"
+ end
+ end
+
+ def write_eml(content)
+ write_file("emails", nil, content)
+ end
+
+ def write_file(subdir, original_filename, content)
+ relative = File.join(subdir, filename(original_filename))
+ full = File.join(storage, relative)
+ FileUtils.mkdir_p(File.dirname(full))
+ mode = content.encoding == Encoding::ASCII_8BIT ? "wb" : "w"
+ File.write(full, content, mode: mode)
+ relative
+ end
+
+ def filename(original = nil)
+ date = Time.now.strftime("%Y-%m-%d")
+ uuid = SecureRandom.uuid
+ base = "#{date}-#{uuid}"
+ return "#{base}.eml" unless original
+
+ "#{base}-#{original}"
+ end
+
+ def storage
+ @storage
+ end
+ end
+
+ class << self
+ def parse_multipart(request, config, db)
+ email_field = request.params["email"]
+ attachments = []
+
+ request.params.each do |key, value|
+ next unless key.to_s.match?(/^attachment\d+$/)
+
+ att = attachment_from_param(value)
+ attachments << att if att
+ end
+
+ [email_field, attachments]
+ end
+
+ def attachment_from_param(value)
+ if value.respond_to?(:tempfile) && value.respond_to?(:original_filename)
+ { filename: value.original_filename, tempfile: value }
+ elsif value.is_a?(Hash) && value[:tempfile].is_a?(Tempfile)
+ { filename: value[:filename], tempfile: value[:tempfile] }
+ end
+ end
+
+ def handle(roda)
+ app = roda.opts[:app]
+ email_field, attachments = parse_multipart(roda.request, app.config, app.db)
+ unless email_field
+ roda.response.status = 400
+ roda.response.write "Bad Request: missing email field"
+ return
+ end
+
+ Processor.new(app).call(email_field: email_field, attachments: attachments)
+ roda.response.status = 200
+ roda.response.write "OK"
+ rescue StandardError => e
+ roda.response.status = 500
+ roda.response.write "Error: #{e.message}"
+ end
+ end
+ end
+end
+```
+
+**Step 2: Run the ingestion tests**
+
+```
+bundle exec ruby -Itest test/test_email_ingestion.rb
+```
+
+Expected: all tests pass.
+
+**Step 3: Run the full test suite**
+
+```
+bundle exec rake test
+```
+
+Expected: all tests pass including `test_ip_allowlist.rb`.
+
+---
+
+### Task 4: Replace model files
+
+The old stubs (`email.rb`, `pdf.rb`, `image.rb`, `attachment.rb`) are not loaded anywhere. Delete them and add models for the new tables.
+
+**Files:**
+- Delete: `lib/models/email.rb`, `lib/models/pdf.rb`, `lib/models/image.rb`, `lib/models/attachment.rb`
+- Create: `lib/models/document.rb`, `lib/models/email_attachment.rb`
+
+**Step 1: Delete old model files**
+
+```
+rm lib/models/email.rb lib/models/pdf.rb lib/models/image.rb lib/models/attachment.rb
+```
+
+**Step 2: Create `lib/models/document.rb`**
+
+```ruby
+# frozen_string_literal: true
+
+module Domus
+ class Document < Sequel::Model
+ one_to_many :outbound_attachments, class: :EmailAttachment, key: :email_id
+ one_to_many :inbound_attachments, class: :EmailAttachment, key: :document_id
+ end
+end
+```
+
+**Step 3: Create `lib/models/email_attachment.rb`**
+
+```ruby
+# frozen_string_literal: true
+
+module Domus
+ class EmailAttachment < Sequel::Model
+ many_to_one :email, class: :Document, key: :email_id
+ many_to_one :document, class: :Document, key: :document_id
+ end
+end
+```
+
+**Step 4: Run the full test suite again**
+
+```
+bundle exec rake test
+```
+
+Expected: still all green.
+
+---
+
+### Task 5: Commit
+
+**Step 1: Check what's staged**
+
+```
+jj diff --stat
+```
+
+**Step 2: Commit**
+
+```
+jj commit -m "Unify document storage into documents and email_attachments tables
+
+Replace the separate emails, pdfs, and images tables and the polymorphic
+attachments join with a single documents table (kind column) and a clean
+email_attachments join. Emails are now first-class documents.
+
+Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>"
+```
diff --git a/lib/config.rb b/lib/config.rb
index 198edaf..0081047 100644
--- a/lib/config.rb
+++ b/lib/config.rb
@@ -4,10 +4,14 @@
module Domus
Config = Data.define(
:database_url, #: String
+ :storage_path #: String
)
class Config
#: () -> Config
- def self.env = new(database_url: ENV.fetch("DATABASE_URL") { "db/domus.db" })
+ def self.env = new(
+ database_url: ENV.fetch("DATABASE_URL") { "db/domus.db" },
+ storage_path: ENV.fetch("STORAGE_PATH") { "storage" },
+ )
end
end
diff --git a/lib/ingest_email.rb b/lib/ingest_email.rb
new file mode 100644
index 0000000..1bc819a
--- /dev/null
+++ b/lib/ingest_email.rb
@@ -0,0 +1,143 @@
+# frozen_string_literal: true
+
+require "securerandom"
+require "fileutils"
+
+module Domus
+ class IngestEmail
+ def initialize(app)
+ @config = app.config
+ @db = app.db
+ @storage = app.config.storage_path
+ end
+
+ def call(email_field:, attachments: [])
+ now = Time.now
+ eml_path = nil
+ written_files = []
+ committed = false
+
+ db.transaction do
+ eml_path = write_eml(email_field, now)
+ written_files << eml_path
+
+ email_doc_id = db[:documents].insert(
+ path: eml_path,
+ kind: "email",
+ received_at: now,
+ created_at: now
+ )
+
+ attachments.each do |att|
+ kind = classify(att[:filename])
+ content = att[:tempfile].read
+ relative = write_file(storage_subdir(kind), att[:filename], content, now)
+ written_files << relative
+
+ doc_id = db[:documents].insert(
+ path: relative,
+ kind: kind.to_s,
+ received_at: now,
+ created_at: now
+ )
+
+ db[:email_attachments].insert(
+ email_id: email_doc_id,
+ document_id: doc_id,
+ filename: att[:filename],
+ created_at: now
+ )
+ end
+
+ committed = true
+ end
+
+ { email_path: eml_path }
+ ensure
+ written_files.each { |rel| FileUtils.rm_f(File.join(@storage, rel)) } unless committed
+ end
+
+ class << self
+ def handle(roda)
+ app = roda.opts[:app]
+ email_field, attachments = parse_multipart(roda.request)
+ unless email_field
+ roda.response.status = 400
+ roda.response.write "Bad Request: missing email field"
+ return
+ end
+
+ new(app).call(email_field: email_field, attachments: attachments)
+ roda.response.status = 200
+ roda.response.write "OK"
+ rescue StandardError => e
+ roda.response.status = 500
+ roda.response.write "Error: #{e.message}"
+ end
+
+ def parse_multipart(request)
+ email_field = request.params["email"]
+ attachments = []
+
+ request.params.each do |key, value|
+ next unless key.to_s.match?(/^attachment\d+$/)
+
+ att = attachment_from_param(value)
+ attachments << att if att
+ end
+
+ [email_field, attachments]
+ end
+
+ def attachment_from_param(value)
+ if value.respond_to?(:tempfile) && value.respond_to?(:original_filename)
+ { filename: value.original_filename, tempfile: value }
+ elsif value.is_a?(Hash) && value[:tempfile].is_a?(Tempfile)
+ { filename: value[:filename], tempfile: value[:tempfile] }
+ end
+ end
+ end
+
+ private
+
+ attr_reader :db
+
+ def classify(filename)
+ ext = File.extname(filename).downcase
+ return :pdf if ext == ".pdf"
+ return :image if %w[.jpg .jpeg .png .gif .webp .bmp .tiff .tif .svg].include?(ext)
+
+ :unknown
+ end
+
+ def storage_subdir(kind)
+ case kind
+ when :pdf then "pdfs"
+ when :image then "images"
+ else "files"
+ end
+ end
+
+ def write_eml(content, now)
+ write_file("emails", nil, content, now)
+ end
+
+ def write_file(subdir, original_filename, content, now)
+ relative = File.join(subdir, filename(original_filename, now))
+ full = File.join(@storage, relative)
+ FileUtils.mkdir_p(File.dirname(full))
+ mode = content.encoding == Encoding::ASCII_8BIT ? "wb" : "w"
+ File.write(full, content, mode: mode)
+ relative
+ end
+
+ def filename(original = nil, now = Time.now)
+ date = now.strftime("%Y-%m-%d")
+ uuid = SecureRandom.uuid
+ base = "#{date}-#{uuid}"
+ return "#{base}.eml" unless original
+
+ "#{base}-#{File.basename(original)}"
+ end
+ end
+end
diff --git a/lib/middleware/ip_allowlist.rb b/lib/middleware/ip_allowlist.rb
new file mode 100644
index 0000000..bb45b33
--- /dev/null
+++ b/lib/middleware/ip_allowlist.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require "ipaddr"
+
+module Domus
+ module Middleware
+ class IpAllowlist
+ # Source: https://sendgrid.com/en-us/blog/smtp-ip-range-sendgrid-2024
+ DEFAULT_SENDGRID_IPS = %w[
+ 167.89.115.0/24
+ 167.89.114.0/24
+ 159.183.224.0/20
+ 159.183.240.0/20
+ ].freeze
+
+ def self.allowed?(addr, ips = DEFAULT_SENDGRID_IPS)
+ ips.map { |ip| IPAddr.new(ip) }.any? { |range| range.include?(addr) }
+ end
+
+ def initialize(app, allowed: nil)
+ @app = app
+ @allowed = build_allowed(allowed || DEFAULT_SENDGRID_IPS)
+ end
+
+ def call(env)
+ addr = env["REMOTE_ADDR"]
+ return [403, {}, ["Forbidden"]] unless addr && allowed?(addr)
+
+ @app.call(env)
+ end
+
+ private
+
+ def build_allowed(ips)
+ ips.map { |ip| IPAddr.new(ip) }
+ end
+
+ def allowed?(addr)
+ @allowed.any? { |range| range.include?(addr) }
+ end
+ end
+ end
+end
diff --git a/lib/models.rb b/lib/models.rb
new file mode 100644
index 0000000..fd06e18
--- /dev/null
+++ b/lib/models.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Domus
+ class Document < Sequel::Model
+ one_to_many :email_attachments, key: :email_id, class: :EmailAttachment
+ end
+
+ class EmailAttachment < Sequel::Model
+ many_to_one :email, class: :Document, key: :email_id
+ many_to_one :document, class: :Document, key: :document_id
+ end
+end
diff --git a/lib/web.rb b/lib/web.rb
index d4d1f86..41750ff 100644
--- a/lib/web.rb
+++ b/lib/web.rb
@@ -2,6 +2,8 @@
require "roda"
require_relative "views/layout"
+require_relative "ingest_email"
+require_relative "middleware/ip_allowlist"
module Domus
class Web < Roda
@@ -9,6 +11,15 @@ module Domus
r.root do
render_with_layout { "ok" }
end
+
+ r.post "inbound/email" do
+ ip = request.env["REMOTE_ADDR"]
+ unless ip && Middleware::IpAllowlist.allowed?(ip)
+ response.status = 403
+ next "Forbidden"
+ end
+ IngestEmail.handle(self)
+ end
end
private
diff --git a/sig/generated/config.rbs b/sig/generated/config.rbs
index 98e9aa1..0c3c558 100644
--- a/sig/generated/config.rbs
+++ b/sig/generated/config.rbs
@@ -4,12 +4,14 @@ module Domus
class Config < Data
attr_reader database_url(): String
- def self.new: (String database_url) -> instance
- | (database_url: String) -> instance
+ attr_reader storage_path(): String
- def self.members: () -> [ :database_url ]
+ def self.new: (String database_url, String storage_path) -> instance
+ | (database_url: String, storage_path: String) -> instance
- def members: () -> [ :database_url ]
+ def self.members: () -> [ :database_url, :storage_path ]
+
+ def members: () -> [ :database_url, :storage_path ]
end
class Config
diff --git a/test/test_helper.rb b/test/test_helper.rb
index cb093a3..a328a0e 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -1,11 +1,17 @@
# frozen_string_literal: true
require "minitest/autorun"
+require "sequel"
+require "sequel/extensions/migration"
+require "fileutils"
require_relative "../lib/app"
require_relative "../lib/web"
-app = Domus::App.new(Domus::Config.new(database_url: ":memory:"))
+storage = Dir.mktmpdir("domus-test")
+at_exit { FileUtils.rm_rf(storage) }
+
+app = Domus::App.new(Domus::Config.new(database_url: ":memory:", storage_path: storage))
migrate_dir = File.expand_path("../db/migrate", __dir__)
Sequel::Migrator.run(app.db, migrate_dir) unless Dir.empty?(migrate_dir)
Domus::Web.opts[:app] = app
diff --git a/test/test_ingest_email.rb b/test/test_ingest_email.rb
new file mode 100644
index 0000000..c98de55
--- /dev/null
+++ b/test/test_ingest_email.rb
@@ -0,0 +1,139 @@
+# frozen_string_literal: true
+
+require_relative "test_helper"
+require "rack/test"
+require "tempfile"
+require "fileutils"
+require "sequel"
+require_relative "../lib/ingest_email"
+require_relative "../lib/config"
+
+class TestIngestEmail < Minitest::Test
+ include Rack::Test::Methods
+
+ def app
+ Domus::Web
+ end
+
+ def setup
+ @test_storage = Dir.mktmpdir("domus-email-test")
+ @config = Domus::Config.new(database_url: ":memory:", storage_path: @test_storage)
+ @app = Domus::App.new(@config)
+ Sequel::Migrator.run(@app.db, migrate_dir)
+
+ @old_app = Domus::Web.opts[:app]
+ Domus::Web.opts[:app] = @app
+ end
+
+ def teardown
+ Domus::Web.opts[:app] = @old_app
+ FileUtils.rm_rf(@test_storage)
+ end
+
+ def test_rejects_missing_email_field
+ post "/inbound/email", {}, {"REMOTE_ADDR" => "167.89.115.100"}
+ assert_equal 400, last_response.status
+ end
+
+ def test_accepts_email_without_attachments
+ post "/inbound/email", {email: sample_email}, {"REMOTE_ADDR" => "167.89.115.100"}
+ assert_equal 200, last_response.status
+
+ email_doc = @app.db[:documents].where(kind: "email").first
+ refute_nil email_doc
+ assert_match %r{emails/\d{4}-\d{2}-\d{2}-.+\.eml}, email_doc[:path]
+ end
+
+ def test_stores_eml_file
+ post "/inbound/email", {email: sample_email}, {"REMOTE_ADDR" => "167.89.115.100"}
+ assert_equal 200, last_response.status
+
+ email_doc = @app.db[:documents].where(kind: "email").first
+ full_path = File.join(@test_storage, email_doc[:path])
+ assert File.exist?(full_path)
+ assert_equal sample_email, File.read(full_path)
+ end
+
+ def test_stores_pdf_attachment
+ post "/inbound/email",
+ {email: sample_email,
+ attachment1: attachment_param("report.pdf", "application/pdf", "pdf content")},
+ {"REMOTE_ADDR" => "167.89.115.100"}
+
+ assert_equal 200, last_response.status
+
+ ea = @app.db[:email_attachments].first
+ refute_nil ea
+ assert_match(/report\.pdf/, ea[:filename])
+
+ email_doc = @app.db[:documents].where(kind: "email").first
+ assert_equal email_doc[:id], ea[:email_id]
+
+ att_doc = @app.db[:documents].where(id: ea[:document_id]).first
+ full_path = File.join(@test_storage, att_doc[:path])
+ assert File.exist?(full_path)
+ assert_equal "pdf content", File.read(full_path)
+ end
+
+ def test_stores_multiple_attachments
+ post "/inbound/email",
+ {email: sample_email,
+ attachment1: attachment_param("a.txt", "text/plain", "aaa"),
+ attachment2: attachment_param("b.txt", "text/plain", "bbb")},
+ {"REMOTE_ADDR" => "167.89.115.100"}
+
+ assert_equal 200, last_response.status
+ assert_equal 2, @app.db[:email_attachments].count
+ assert_equal 3, @app.db[:documents].count
+ end
+
+ def test_attachment_linked_to_correct_email
+ post "/inbound/email",
+ {email: sample_email,
+ attachment1: attachment_param("doc.pdf", "application/pdf", "data")},
+ {"REMOTE_ADDR" => "167.89.115.100"}
+
+ email_doc = @app.db[:documents].where(kind: "email").first
+ ea = @app.db[:email_attachments].first
+ assert_equal email_doc[:id], ea[:email_id]
+ end
+
+ def test_pdf_attachment_has_correct_kind
+ post "/inbound/email",
+ {email: sample_email,
+ attachment1: attachment_param("file.pdf", "application/pdf", "data")},
+ {"REMOTE_ADDR" => "167.89.115.100"}
+
+ ea = @app.db[:email_attachments].first
+ doc = @app.db[:documents].where(id: ea[:document_id]).first
+ assert_equal "pdf", doc[:kind]
+ end
+
+ def test_attachment_path_includes_random_uuid
+ post "/inbound/email",
+ {email: sample_email,
+ attachment1: attachment_param("file.pdf", "application/pdf", "data")},
+ {"REMOTE_ADDR" => "167.89.115.100"}
+
+ ea = @app.db[:email_attachments].first
+ doc = @app.db[:documents].where(id: ea[:document_id]).first
+ assert_match %r{pdfs/\d{4}-\d{2}-\d{2}-.+file\.pdf}, doc[:path]
+ end
+
+ private
+
+ def migrate_dir
+ File.expand_path("../db/migrate", __dir__)
+ end
+
+ def sample_email
+ "From: sender@example.com\nSubject: Test Subject\n\nBody"
+ end
+
+ def attachment_param(filename, type, content)
+ tempfile = Tempfile.new(filename)
+ tempfile.write(content)
+ tempfile.rewind
+ Rack::Test::UploadedFile.new(tempfile.path, type, original_filename: filename)
+ end
+end
diff --git a/test/test_ip_allowlist.rb b/test/test_ip_allowlist.rb
new file mode 100644
index 0000000..5f15f09
--- /dev/null
+++ b/test/test_ip_allowlist.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require_relative "test_helper"
+require "rack/test"
+require_relative "../lib/middleware/ip_allowlist"
+
+class TestIpAllowlist < Minitest::Test
+ include Rack::Test::Methods
+
+ def app
+ inner = proc { |env| [200, {}, ["ok"]] }
+ Domus::Middleware::IpAllowlist.new(inner, allowed: ["167.89.115.0/24"])
+ end
+
+ def test_allows_request_from_listed_ip
+ get "/", {}, { "REMOTE_ADDR" => "167.89.115.100" }
+ assert_equal 200, last_response.status
+ end
+
+ def test_blocks_request_from_unlisted_ip
+ get "/", {}, { "REMOTE_ADDR" => "10.0.0.1" }
+ assert_equal 403, last_response.status
+ end
+
+ def test_blocks_request_without_remote_addr
+ get "/"
+ assert_equal 403, last_response.status
+ end
+
+
+end