diff --git a/docs/modules/cyrus.md b/docs/modules/cyrus.md
new file mode 100644
index 00000000000..664998722ec
--- /dev/null
+++ b/docs/modules/cyrus.md
@@ -0,0 +1,66 @@
+# Cyrus
+
+!!! note
+ This module is INCUBATING. While it is ready for use and operational in the current version of Testcontainers, it is possible that it may receive breaking changes in the future. See [our contributing guidelines](/contributing/#incubating-modules) for more information on our incubating modules policy.
+
+Testcontainers module for [Cyrus Docker Test Server](https://github.com/cyrusimap/cyrus-docker-test-server).
+
+## CyrusContainer usage example
+
+You can start a Cyrus container instance from any Java application by using:
+
+
+[Create a CyrusContainer](../../modules/cyrus/src/test/java/org/testcontainers/cyrus/CyrusContainerTest.java) inside_block:container
+
+
+The container exposes helpers for:
+
+* protocol endpoints (`IMAP`, `POP3`, `HTTP/JMAP`, `LMTP`, `SIEVE`)
+* strict management operations (`exportUser`, `importUser`, `deleteUser`)
+* idempotent runtime helpers (`userExists`, `deleteUserIfExists`, `exportUserIfExists`, `createUserIfMissing`)
+* startup user seeding (`withSeedEmptyUser`, `withSeedUser`, `withSeedUsers`) with deterministic replace behavior
+* official image environment variables (`REFRESH`, `CYRUS_VERSION`, `DEFAULTDOMAIN`, `SERVERNAME`, `RELAYHOST`, `RELAYAUTH`)
+
+## User Builder
+
+Create a default empty user payload with `CyrusUser.builder(...)`:
+
+
+[Build a default Cyrus user](../../modules/cyrus/src/test/java/org/testcontainers/cyrus/CyrusUserTest.java) inside_block:userBuilder
+
+
+## Startup Seeding
+
+Seed users during container startup:
+
+
+[Seed users at startup](../../modules/cyrus/src/test/java/org/testcontainers/cyrus/CyrusContainerTest.java) inside_block:startupSeeding
+
+
+When the same user is seeded multiple times, the last declaration wins.
+
+## Runtime User Management
+
+Use strict methods when missing users should fail fast, and idempotent methods when they should not:
+
+* strict: `exportUser`, `importUser`, `deleteUser`
+* idempotent: `userExists`, `deleteUserIfExists`, `exportUserIfExists`, `createUserIfMissing`
+
+## Adding this module to your project dependencies
+
+Add the following dependency to your `pom.xml`/`build.gradle` file:
+
+=== "Gradle"
+ ```groovy
+ testImplementation "org.testcontainers:testcontainers-cyrus:{{latest_version}}"
+ ```
+
+=== "Maven"
+ ```xml
+
+ org.testcontainers
+ testcontainers-cyrus
+ {{latest_version}}
+ test
+
+ ```
diff --git a/mkdocs.yml b/mkdocs.yml
index 04842f28fcd..0633def8c49 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -80,6 +80,7 @@ nav:
- modules/azure.md
- modules/chromadb.md
- modules/consul.md
+ - modules/cyrus.md
- modules/docker_compose.md
- modules/docker_mcp_gateway.md
- modules/docker_model_runner.md
diff --git a/modules/cyrus/build.gradle b/modules/cyrus/build.gradle
new file mode 100644
index 00000000000..5aaa94fd209
--- /dev/null
+++ b/modules/cyrus/build.gradle
@@ -0,0 +1,6 @@
+description = "Testcontainers :: Cyrus"
+
+dependencies {
+ api project(':testcontainers')
+ testImplementation 'com.sun.mail:jakarta.mail:2.0.1'
+}
diff --git a/modules/cyrus/hs_err_pid28384.log b/modules/cyrus/hs_err_pid28384.log
new file mode 100644
index 00000000000..9397ba79e9b
--- /dev/null
+++ b/modules/cyrus/hs_err_pid28384.log
@@ -0,0 +1,287 @@
+#
+# There is insufficient memory for the Java Runtime Environment to continue.
+# Native memory allocation (mmap) failed to map 1073741824 bytes. Error detail: G1 virtual space
+# Possible reasons:
+# The system is out of physical RAM or swap space
+# This process is running with CompressedOops enabled, and the Java Heap may be blocking the growth of the native heap
+# Possible solutions:
+# Reduce memory load on the system
+# Increase physical memory or swap space
+# Check if swap backing store is full
+# Decrease Java heap size (-Xmx/-Xms)
+# Decrease number of Java threads
+# Decrease Java thread stack sizes (-Xss)
+# Set larger code cache with -XX:ReservedCodeCacheSize=
+# JVM is running with Zero Based Compressed Oops mode in which the Java heap is
+# placed in the first 32GB address space. The Java Heap base address is the
+# maximum limit for the native heap growth. Please use -XX:HeapBaseMinAddress
+# to set the Java Heap base and to place the Java Heap above 32GB virtual address.
+# This output file may be truncated or incomplete.
+#
+# Out of Memory Error (os_windows.cpp:3736), pid=28384, tid=11368
+#
+# JRE version: (21.0.8+9) (build )
+# Java VM: OpenJDK 64-Bit Server VM (21.0.8+9-LTS, mixed mode, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, windows-amd64)
+# No core dump will be written. Minidumps are not enabled by default on client versions of Windows
+#
+
+--------------- S U M M A R Y ------------
+
+Command Line: -Dfile.encoding=UTF-8 -Duser.country=DE -Duser.language=de -Duser.variant lombok.launch.Main delombok C:\Code\github\jguck\testcontainers-java\modules\cyrus\src\main\java -d C:\Code\github\jguck\testcontainers-java\modules\cyrus\build\delombok -f generateDelombokComment:skip
+
+Host: 12th Gen Intel(R) Core(TM) i7-12800HX, 24 cores, 63G, Windows 11 , 64 bit Build 26100 (10.0.26100.7705)
+Time: Thu Mar 5 09:12:19 2026 Mitteleuropäische Zeit elapsed time: 0.012408 seconds (0d 0h 0m 0s)
+
+--------------- T H R E A D ---------------
+
+Current thread (0x0000027e78f83d90): JavaThread "Unknown thread" [_thread_in_vm, id=11368, stack(0x000000bad4c00000,0x000000bad4d00000) (1024K)]
+
+Stack: [0x000000bad4c00000,0x000000bad4d00000]
+Native frames: (J=compiled Java code, j=interpreted, Vv=VM code, C=native code)
+V [jvm.dll+0x6e1729]
+V [jvm.dll+0x8bf47b]
+V [jvm.dll+0x8c19aa]
+V [jvm.dll+0x8c2083]
+V [jvm.dll+0x289fd6]
+V [jvm.dll+0x6de085]
+V [jvm.dll+0x6d218a]
+V [jvm.dll+0x366f6e]
+V [jvm.dll+0x36ee1b]
+V [jvm.dll+0x3c06b9]
+V [jvm.dll+0x3c095b]
+V [jvm.dll+0x33a8b7]
+V [jvm.dll+0x33b3fb]
+V [jvm.dll+0x888e5e]
+V [jvm.dll+0x3cd611]
+V [jvm.dll+0x871e5c]
+V [jvm.dll+0x461261]
+V [jvm.dll+0x462ea1]
+C [jli.dll+0x52f0]
+C [ucrtbase.dll+0x37b0]
+C [KERNEL32.DLL+0x2e8d7]
+C [ntdll.dll+0x8c40c]
+
+
+--------------- P R O C E S S ---------------
+
+Threads class SMR info:
+_java_thread_list=0x00007fff32086288, length=0, elements={
+}
+
+Java Threads: ( => current thread )
+Total: 0
+
+Other Threads:
+ 0x0000027e7b4d6590 WorkerThread "GC Thread#0" [id=48756, stack(0x000000bad4d00000,0x000000bad4e00000) (1024K)]
+ 0x0000027e7b4dd040 ConcurrentGCThread "G1 Main Marker" [id=16236, stack(0x000000bad4e00000,0x000000bad4f00000) (1024K)]
+ 0x0000027e7b4de2e0 WorkerThread "G1 Conc#0" [id=38472, stack(0x000000bad4f00000,0x000000bad5000000) (1024K)]
+
+[error occurred during error reporting (printing all threads), id 0xc0000005, EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007fff3177d307]
+VM state: not at safepoint (not fully initialized)
+
+VM Mutex/Monitor currently owned by a thread: ([mutex/lock_event])
+[0x00007fff320fa8c8] Heap_lock - owner thread: 0x0000027e78f83d90
+
+Heap address: 0x0000000404800000, size: 16312 MB, Compressed Oops mode: Zero based, Oop shift amount: 3
+
+CDS archive(s) mapped at: [0x0000000000000000-0x0000000000000000-0x0000000000000000), size 0, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 1.
+Narrow klass base: 0x0000000000000000, Narrow klass shift: 0, Narrow klass range: 0x0
+
+GC Precious Log:
+ CardTable entry size: 512
+ Card Set container configuration: InlinePtr #cards 4 size 8 Array Of Cards #cards 64 size 144 Howl #buckets 8 coarsen threshold 14745 Howl Bitmap #cards 2048 size 272 coarsen threshold 1843 Card regions per heap region 1 cards per card region 16384
+
+Heap:
+ garbage-first heap total 0K, used 0K [0x0000000404800000, 0x0000000800000000)
+ region size 8192K, 0 young (0K), 0 survivors (0K)
+
+[error occurred during error reporting (printing heap information), id 0xc0000005, EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007fff31b6ba99]
+GC Heap History (0 events):
+No events
+
+Dll operation events (1 events):
+Event: 0.006 Loaded shared library C:\Program Files\Microsoft\jdk-21.0.8.9-hotspot\bin\java.dll
+
+Deoptimization events (0 events):
+No events
+
+Classes loaded (0 events):
+No events
+
+Classes unloaded (0 events):
+No events
+
+Classes redefined (0 events):
+No events
+
+Internal exceptions (0 events):
+No events
+
+ZGC Phase Switch (0 events):
+No events
+
+VM Operations (0 events):
+No events
+
+Memory protections (0 events):
+No events
+
+Nmethod flushes (0 events):
+No events
+
+Events (0 events):
+No events
+
+
+Dynamic libraries:
+0x00007ff749650000 - 0x00007ff74965e000 C:\Program Files\Microsoft\jdk-21.0.8.9-hotspot\bin\java.exe
+0x00007ff8012c0000 - 0x00007ff801528000 C:\WINDOWS\SYSTEM32\ntdll.dll
+0x00007fffffe20000 - 0x00007fffffee9000 C:\WINDOWS\System32\KERNEL32.DLL
+0x00007ffffec30000 - 0x00007fffff021000 C:\WINDOWS\System32\KERNELBASE.dll
+0x00007ffffdcd0000 - 0x00007ffffde1b000 C:\WINDOWS\System32\ucrtbase.dll
+0x00007fffe0b90000 - 0x00007fffe0ba8000 C:\Program Files\Microsoft\jdk-21.0.8.9-hotspot\bin\jli.dll
+0x00007fffe0b70000 - 0x00007fffe0b8e000 C:\Program Files\Microsoft\jdk-21.0.8.9-hotspot\bin\VCRUNTIME140.dll
+0x00007ff800fa0000 - 0x00007ff801166000 C:\WINDOWS\System32\USER32.dll
+0x00007ffffe9f0000 - 0x00007ffffea17000 C:\WINDOWS\System32\win32u.dll
+0x00007fffe97d0000 - 0x00007fffe9a63000 C:\WINDOWS\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.26100.7824_none_3e0870b2e3345462\COMCTL32.dll
+0x00007ff8005f0000 - 0x00007ff80061b000 C:\WINDOWS\System32\GDI32.dll
+0x00007fffffd70000 - 0x00007fffffe19000 C:\WINDOWS\System32\msvcrt.dll
+0x00007ffffde20000 - 0x00007ffffdf4b000 C:\WINDOWS\System32\gdi32full.dll
+0x00007ffffdb70000 - 0x00007ffffdc13000 C:\WINDOWS\System32\msvcp_win.dll
+0x00007ff800dc0000 - 0x00007ff800df1000 C:\WINDOWS\System32\IMM32.DLL
+0x00007fffe0c20000 - 0x00007fffe0c2c000 C:\Program Files\Microsoft\jdk-21.0.8.9-hotspot\bin\vcruntime140_1.dll
+0x00007fff8d500000 - 0x00007fff8d589000 C:\Program Files\Microsoft\jdk-21.0.8.9-hotspot\bin\msvcp140.dll
+0x00007fff31430000 - 0x00007fff321dc000 C:\Program Files\Microsoft\jdk-21.0.8.9-hotspot\bin\server\jvm.dll
+0x00007ff800970000 - 0x00007ff800a24000 C:\WINDOWS\System32\ADVAPI32.dll
+0x00007fffffa50000 - 0x00007fffffaf6000 C:\WINDOWS\System32\sechost.dll
+0x00007fffff930000 - 0x00007fffffa48000 C:\WINDOWS\System32\RPCRT4.dll
+0x00007fffffbe0000 - 0x00007fffffc54000 C:\WINDOWS\System32\WS2_32.dll
+0x00007ffffc760000 - 0x00007ffffc7be000 C:\WINDOWS\SYSTEM32\POWRPROF.dll
+0x00007ffff5c00000 - 0x00007ffff5c0b000 C:\WINDOWS\SYSTEM32\VERSION.dll
+0x00007ffff54c0000 - 0x00007ffff54f5000 C:\WINDOWS\SYSTEM32\WINMM.dll
+0x00007ffffc740000 - 0x00007ffffc754000 C:\WINDOWS\SYSTEM32\UMPDC.dll
+0x00007ffffca10000 - 0x00007ffffca2b000 C:\WINDOWS\SYSTEM32\kernel.appcore.dll
+0x00007fffc7580000 - 0x00007fffc758a000 C:\Program Files\Microsoft\jdk-21.0.8.9-hotspot\bin\jimage.dll
+0x00007ffffb6c0000 - 0x00007ffffb902000 C:\WINDOWS\SYSTEM32\DBGHELP.DLL
+0x00007ff800a30000 - 0x00007ff800db6000 C:\WINDOWS\System32\combase.dll
+0x00007fffffb00000 - 0x00007fffffbd7000 C:\WINDOWS\System32\OLEAUT32.dll
+0x00007fffcd350000 - 0x00007fffcd38b000 C:\WINDOWS\SYSTEM32\dbgcore.DLL
+0x00007ffffdc20000 - 0x00007ffffdcc5000 C:\WINDOWS\System32\bcryptPrimitives.dll
+0x00007fffc7550000 - 0x00007fffc7571000 C:\Program Files\Microsoft\jdk-21.0.8.9-hotspot\bin\java.dll
+
+JVMTI agents: none
+
+dbghelp: loaded successfully - version: 4.0.5 - missing functions: none
+symbol engine: initialized successfully - sym options: 0x614 - pdb path: .;C:\Program Files\Microsoft\jdk-21.0.8.9-hotspot\bin;C:\WINDOWS\SYSTEM32;C:\WINDOWS\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.26100.7824_none_3e0870b2e3345462;C:\Program Files\Microsoft\jdk-21.0.8.9-hotspot\bin\server
+
+VM Arguments:
+jvm_args: -Dfile.encoding=UTF-8 -Duser.country=DE -Duser.language=de -Duser.variant
+java_command: lombok.launch.Main delombok C:\Code\github\jguck\testcontainers-java\modules\cyrus\src\main\java -d C:\Code\github\jguck\testcontainers-java\modules\cyrus\build\delombok -f generateDelombokComment:skip
+java_class_path (initial): C:\Code\github\jguck\testcontainers-java\core\build\classes\java\main;C:\Users\joche\.gradle\caches\modules-2\files-2.1\org.projectlombok\lombok\1.18.30\f195ee86e6c896ea47a1d39defbe20eb59cd149d\lombok-1.18.30.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\org.zeroturnaround\zt-exec\1.12\51a8d135518365a169a8c94e074c7eaaf864e147\zt-exec-1.12.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\com.github.docker-java\docker-java-core\3.7.0\760ff9345121d4fd6ad0729035a8873bed15b66d\docker-java-core-3.7.0.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\com.github.docker-java\docker-java-api\3.7.0\47d3e92a30fc1832197602cb1214b866024372cb\docker-java-api-3.7.0.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\com.github.docker-java\docker-java-transport-zerodep\3.7.0\55185d3af96befa953dcb0b89de3751f95999511\docker-java-transport-zerodep-3.7.0.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\org.slf4j\slf4j-api\1.7.36\6c62681a2f655b49963a5983b8b0950a6120ae14\slf4j-api-1.7.36.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\org.apache.commons\commons-compress\1.28.0\e482f2c7a88dac3c497e96aa420b6a769f59c8d7\commons-compress-1.28.0.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\org.rnorth.duct-tape\duct-tape\1.0.8\92edc22a9ab2f3e17c9bf700aaee377d50e8b530\duct-tape-1.0.8.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\com.google.cloud.tools\jib-core\0.27.3\5916b17257df977aea79fd934b9f62a5d16e396c\jib-core-0.27.3.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\org.awaitility\awaitility\4.3.0\f0c0bc1e404e500bab3f498b922eaedeae1c0207\awaitility-4.3.0.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\com.google.guava\guava\33.3.1-jre\852f8b363da0111e819460021ca693cacca3e8db\guava-33.3.1-jre.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\org.yaml\snakeyaml\2.5\2d53ddec134280cb384c1e35d094e5f71c1f2316\snakeyaml-2.5.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\org.glassfish.main.external\trilead-ssh2-repackaged\4.1.2\5b72873bd34dff3fda779ade0b9a3e3a4aea94be\trilead-ssh2-repackaged-4.1.2.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\com.github.docker-java\docker-java-transport\3.7.0\e68716970e65eb45c01c0207d15ff03bb32edf49\docker-java-transport-3.7.0.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\commons-codec\commons-codec\1.19.0\8c0dbe3ae883fceda9b50a6c76e745e548073388\commons-codec-1.19.0.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\commons-io\commons-io\2.21.0\52a6f68fe5afe335cde95461dd5c3412f04996f7\commons-io-2.21.0.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\org.apache.commons\commons-lang3\3.19.0\d6524b169a6574cd253760c472d419b47bfd37e6\commons-lang3-3.19.0.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\com.google.cloud.tools\jib-build-plan\0.4.0\b16394e7eda9aeff338841c6dc47ed5a8a9d8120\jib-build-plan-0.4.0.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\org.hamcrest\hamcrest\2.1\9420ba32c29217b54eebd26ff7f9234d31c3fbb2\hamcrest-2.1.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\com.google.guava\failureaccess\1.0.2\c4a06a64e650562f30b7bf9aaec1bfed43aca12b\failureaccess-1.0.2.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\com.google.guava\listenablefuture\9999.0-empty-to-avoid-conflict-with-guava\b421526c5f297295adef1c886e5246c39d4ac629\listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\com.google.code.findbugs\jsr305\3.0.2\25ea2e8b0c338a877313bd4672d3fe056ea78f0d\jsr305-3.0.2.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\org.checkerframework\checker-qual\3.43.0\9425eee39e56b116d2b998b7c2cebcbd11a3c98b\checker-qual-3.43.0.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\com.google.errorprone\error_prone_annotations\2.28.0\59fc00087ce372de42e394d2c789295dff2d19f0\error_prone_annotations-2.28.0.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\com.google.j2objc\j2objc-annotations\3.0.0\7399e65dd7e9ff3404f4535b2f017093bdb134c7\j2objc-annotations-3.0.0.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-core\2.20.1\5734323adfece72111769b0ae38a6cf803e3d178\jackson-core-2.20.1.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-databind\2.20.1\9586a7fe0e1775de0e54237fa6a2c8455c93ac06\jackson-databind-2.20.1.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-annotations\2.20\6a5e7291ea3f2b590a7ce400adb7b3aea4d7e12c\jackson-annotations-2.20.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\org.bouncycastle\bcpkix-jdk18on\1.82\ad7b7155abac3e4e4f73579d5176c11f7659c560\bcpkix-jdk18on-1.82.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\net.java.dev.jna\jna\5.18.1\b27ba04287cc4abe769642fe8318d39fc89bf937\jna-5.18.1.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\org.bouncycastle\bcutil-jdk18on\1.82\1850911d674c91ce6444783ff10478e2c6e9bbf9\bcutil-jdk18on-1.82.jar;C:\Users\joche\.gradle\caches\modules-2\files-2.1\org.bouncycastle\bcprov-jdk18on\1.82\e1118397395d21909a1b7b15120d0c2a68d7fd0c\bcprov-jdk18on-1.82.jar
+Launcher Type: SUN_STANDARD
+
+[Global flags]
+ intx CICompilerCount = 12 {product} {ergonomic}
+ uint ConcGCThreads = 5 {product} {ergonomic}
+ uint G1ConcRefinementThreads = 18 {product} {ergonomic}
+ size_t G1HeapRegionSize = 8388608 {product} {ergonomic}
+ uintx GCDrainStackTargetSize = 64 {product} {ergonomic}
+ size_t InitialHeapSize = 1073741824 {product} {ergonomic}
+ size_t MarkStackSize = 4194304 {product} {ergonomic}
+ size_t MaxHeapSize = 17104371712 {product} {ergonomic}
+ size_t MinHeapDeltaBytes = 8388608 {product} {ergonomic}
+ size_t MinHeapSize = 8388608 {product} {ergonomic}
+ uintx NonNMethodCodeHeapSize = 7602480 {pd product} {ergonomic}
+ uintx NonProfiledCodeHeapSize = 122027880 {pd product} {ergonomic}
+ uintx ProfiledCodeHeapSize = 122027880 {pd product} {ergonomic}
+ uintx ReservedCodeCacheSize = 251658240 {pd product} {ergonomic}
+ bool SegmentedCodeCache = true {product} {ergonomic}
+ size_t SoftMaxHeapSize = 17104371712 {manageable} {ergonomic}
+ bool UseCompressedOops = true {product lp64_product} {ergonomic}
+ bool UseG1GC = true {product} {ergonomic}
+ bool UseLargePagesIndividualAllocation = false {pd product} {ergonomic}
+
+Logging:
+Log output configuration:
+ #0: stdout all=warning uptime,level,tags foldmultilines=false
+ #1: stderr all=off uptime,level,tags foldmultilines=false
+
+Environment Variables:
+JAVA_HOME=C:\Program Files\Microsoft\jdk-21.0.8.9-hotspot\
+PATH=C:\Users\joche\.codex\tmp\arg0\codex-arg0zDBWIO;C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v13.1\bin\x64;C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v13.1\bin;C:\Program Files\Microsoft\jdk-21.0.8.9-hotspot\bin;C:\Program Files\Eclipse Adoptium\jdk-21.0.6.7-hotspot\bin;C:\Python313\Scripts\;C:\Python313\;C:\Python312\Scripts\;C:\Python312\;C:\Python311\Scripts\;C:\Python311\;C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v12.1\bin;C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v12.1\libnvvp;C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v11.7\bin;C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v11.7\libnvvp;C:\Windows\system32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0\;C:\Windows\System32\OpenSSH\;C:\ProgramData\chocolatey\bin;C:\ProgramData\nvm;C:\Program Files\nodejs;C:\Program Files (x86)\Google\Cloud SDK\google-cloud-sdk\bin;C:\Program Files (x86)\NVIDIA Corporation\PhysX\Common;C:\Program Files\Java\jdk1.8.0_211\bin;C:\Android\android-sdk\tools\bin;C:\Program Files (x86)\Lua\5.1;C:\Program Files (x86)\Lua\5.1\clibs;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;C:\ProgramData\chocolatey\lib\maven\apache-maven-3.9.11\bin;C:\Program Files\Docker\Docker\resources\bin;C:\Program Files\Git\cmd;C:\Program Files\NVIDIA Corporation\Nsight Compute 2025.4.1\;C:\Users\joche\AppData\Local\Microsoft\WindowsApps;C:\Users\joche\AppData\Local\JetBrains\Toolbox\scripts;C:\Users\joche\AppData\Local\GitHubDesktop\bin;C:\ProgramData\chocolatey\lib\SQLite\tools;C:\ProgramData\chocolatey\lib\gcloudsdk\tools\google-cloud-sdk\bin;C:\Users\joche\.krew\bin;C:\Users\joche\AppData\Local\Microsoft\WindowsApps;C:\Users\joche\AppData\Local\Programs\Microsoft VS Code\bin;C:\Code\gitea\plantitude.ai\runtime\products\tdm\build\install\tdm-shadow\bin;C:\Code\gitea\plantitude.ai\runtime\products\plantitool\build\install\plantitool-shadow\bin;C:\Code\gitea\plantitude.ai\runtime\products\tdmV2\build\install\tdmV2-shadow\bin;C:\Program Files (x86)\Nmap;C:\Program Files (x86)\Lua\5.4;C:\Program Files (x86)\Lua\5.4.8;C:\Users\joche\AppData\Local\Programs\MiKTeX\miktex\bin\x64\;C:\Users\joche\AppData\Local\Programs\Ollama;C:\Program Files (x86)\Lua\5.5;;c:\Users\joche\.vscode\extensions\openai.chatgpt-0.4.79-win32-x64\bin\windows-x86_64
+USERNAME=joche
+OS=Windows_NT
+PROCESSOR_IDENTIFIER=Intel64 Family 6 Model 151 Stepping 2, GenuineIntel
+TMP=C:\Users\joche\AppData\Local\Temp
+TEMP=C:\Users\joche\AppData\Local\Temp
+
+
+
+
+Periodic native trim disabled
+
+--------------- S Y S T E M ---------------
+
+OS:
+ Windows 11 , 64 bit Build 26100 (10.0.26100.7705)
+OS uptime: 3 days 17:38 hours
+Hyper-V role detected
+
+CPU: total 24 (initial active 24) (12 cores per cpu, 2 threads per core) family 6 model 151 stepping 2 microcode 0x22, cx8, cmov, fxsr, ht, mmx, 3dnowpref, sse, sse2, sse3, ssse3, sse4.1, sse4.2, popcnt, lzcnt, tsc, tscinvbit, avx, avx2, aes, erms, clmul, bmi1, bmi2, adx, sha, fma, vzeroupper, clflush, clflushopt, clwb, hv, serialize, rdtscp, rdpid, fsrm, f16c, cet_ibt, cet_ss
+Processor Information for processor 0
+ Max Mhz: 2000, Current Mhz: 2000, Mhz Limit: 2000
+Processor Information for processor 1
+ Max Mhz: 2000, Current Mhz: 1520, Mhz Limit: 2000
+Processor Information for processor 2
+ Max Mhz: 2000, Current Mhz: 2000, Mhz Limit: 2000
+Processor Information for processor 3
+ Max Mhz: 2000, Current Mhz: 2000, Mhz Limit: 2000
+Processor Information for processor 4
+ Max Mhz: 2000, Current Mhz: 2000, Mhz Limit: 2000
+Processor Information for processor 5
+ Max Mhz: 2000, Current Mhz: 2000, Mhz Limit: 2000
+Processor Information for processor 6
+ Max Mhz: 2000, Current Mhz: 2000, Mhz Limit: 2000
+Processor Information for processor 7
+ Max Mhz: 2000, Current Mhz: 2000, Mhz Limit: 2000
+Processor Information for processor 8
+ Max Mhz: 2000, Current Mhz: 2000, Mhz Limit: 2000
+Processor Information for processor 9
+ Max Mhz: 2000, Current Mhz: 2000, Mhz Limit: 2000
+Processor Information for processor 10
+ Max Mhz: 2000, Current Mhz: 2000, Mhz Limit: 2000
+Processor Information for processor 11
+ Max Mhz: 2000, Current Mhz: 2000, Mhz Limit: 2000
+Processor Information for processor 12
+ Max Mhz: 2000, Current Mhz: 2000, Mhz Limit: 2000
+Processor Information for processor 13
+ Max Mhz: 2000, Current Mhz: 2000, Mhz Limit: 2000
+Processor Information for processor 14
+ Max Mhz: 2000, Current Mhz: 2000, Mhz Limit: 2000
+Processor Information for processor 15
+ Max Mhz: 2000, Current Mhz: 2000, Mhz Limit: 2000
+Processor Information for processor 16
+ Max Mhz: 2000, Current Mhz: 1466, Mhz Limit: 2000
+Processor Information for processor 17
+ Max Mhz: 2000, Current Mhz: 2000, Mhz Limit: 2000
+Processor Information for processor 18
+ Max Mhz: 2000, Current Mhz: 2000, Mhz Limit: 2000
+Processor Information for processor 19
+ Max Mhz: 2000, Current Mhz: 2000, Mhz Limit: 2000
+Processor Information for processor 20
+ Max Mhz: 2000, Current Mhz: 1466, Mhz Limit: 2000
+Processor Information for processor 21
+ Max Mhz: 2000, Current Mhz: 1466, Mhz Limit: 2000
+Processor Information for processor 22
+ Max Mhz: 2000, Current Mhz: 2000, Mhz Limit: 2000
+Processor Information for processor 23
+ Max Mhz: 2000, Current Mhz: 1466, Mhz Limit: 2000
+
+Memory: 4k page, system-wide physical 65233M (10551M free)
+TotalPageFile size 75626M (AvailPageFile size 881M)
+current process WorkingSet (physical memory assigned to process): 13M, peak: 13M
+current process commit charge ("private bytes"): 82M, peak: 1106M
+
+vm_info: OpenJDK 64-Bit Server VM (21.0.8+9-LTS) for windows-amd64 JRE (21.0.8+9-LTS), built on 2025-07-10T20:22:44Z by "MicrosoftCorporation" with unknown MS VC++:1944
+
+END.
diff --git a/modules/cyrus/src/main/java/org/testcontainers/cyrus/CyrusContainer.java b/modules/cyrus/src/main/java/org/testcontainers/cyrus/CyrusContainer.java
new file mode 100644
index 00000000000..05847cebd07
--- /dev/null
+++ b/modules/cyrus/src/main/java/org/testcontainers/cyrus/CyrusContainer.java
@@ -0,0 +1,441 @@
+package org.testcontainers.cyrus;
+
+import com.github.dockerjava.api.command.InspectContainerResponse;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.utility.DockerImageName;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Testcontainers implementation for Cyrus.
+ *
+ * Supported images:
+ *
+ * - {@code ghcr.io/cyrusimap/cyrus-docker-test-server}
+ * - {@code cyrusimap/cyrus-docker-test-server}
+ *
+ *
+ * Exposed ports:
+ *
+ * - IMAP: 8143
+ * - POP3: 8110
+ * - HTTP (JMAP/CardDAV/CalDAV): 8080
+ * - LMTP: 8024
+ * - SIEVE: 4190
+ * - Management API: 8001
+ *
+ * Supported environment variables:
+ *
+ * - {@code REFRESH}
+ * - {@code CYRUS_VERSION}
+ * - {@code DEFAULTDOMAIN}
+ * - {@code SERVERNAME}
+ * - {@code RELAYHOST}
+ * - {@code RELAYAUTH}
+ *
+ */
+public class CyrusContainer extends GenericContainer {
+
+ private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse(
+ "ghcr.io/cyrusimap/cyrus-docker-test-server"
+ );
+
+ private static final DockerImageName COMPATIBLE_IMAGE_NAME = DockerImageName.parse("cyrusimap/cyrus-docker-test-server");
+
+ private static final int IMAP_PORT = 8143;
+
+ private static final int POP3_PORT = 8110;
+
+ private static final int HTTP_PORT = 8080;
+
+ private static final int LMTP_PORT = 8024;
+
+ private static final int SIEVE_PORT = 4190;
+
+ private static final int MANAGEMENT_PORT = 8001;
+
+ private static final int MANAGEMENT_REQUEST_TIMEOUT_MILLIS = 10_000;
+
+ private static final String SKIP_CREATE_USERS_ENV = "SKIP_CREATE_USERS";
+
+ private static final String[] DEFAULT_USERS = { "user1", "user2", "user3", "user4", "user5" };
+
+ private final Map seedUsers = new LinkedHashMap();
+
+ public CyrusContainer(String imageName) {
+ this(DockerImageName.parse(imageName));
+ }
+
+ public CyrusContainer(DockerImageName dockerImageName) {
+ super(dockerImageName);
+ dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME, COMPATIBLE_IMAGE_NAME);
+
+ withExposedPorts(IMAP_PORT, POP3_PORT, HTTP_PORT, LMTP_PORT, SIEVE_PORT, MANAGEMENT_PORT);
+ waitingFor(Wait.forHttp("/").forPort(MANAGEMENT_PORT).forStatusCode(200).withStartupTimeout(Duration.ofMinutes(2)));
+ }
+
+ public int getImapPort() {
+ return getMappedPort(IMAP_PORT);
+ }
+
+ public String getImapUrl() {
+ return "imap://" + getHost() + ":" + getImapPort();
+ }
+
+ public int getPop3Port() {
+ return getMappedPort(POP3_PORT);
+ }
+
+ public String getPop3Url() {
+ return "pop3://" + getHost() + ":" + getPop3Port();
+ }
+
+ public int getHttpPort() {
+ return getMappedPort(HTTP_PORT);
+ }
+
+ public String getHttpBaseUrl() {
+ return "http://" + getHost() + ":" + getHttpPort();
+ }
+
+ public String getJmapUrl() {
+ return getHttpBaseUrl() + "/jmap/";
+ }
+
+ public int getLmtpPort() {
+ return getMappedPort(LMTP_PORT);
+ }
+
+ public int getSievePort() {
+ return getMappedPort(SIEVE_PORT);
+ }
+
+ public int getManagementPort() {
+ return getMappedPort(MANAGEMENT_PORT);
+ }
+
+ public String getManagementUrl() {
+ return "http://" + getHost() + ":" + getManagementPort();
+ }
+
+ public CyrusContainer withDefaultDomain(String defaultDomain) {
+ withEnv("DEFAULTDOMAIN", defaultDomain);
+ return self();
+ }
+
+ public CyrusContainer withRefresh(boolean refresh) {
+ if (refresh) {
+ withEnv("REFRESH", "1");
+ } else {
+ getEnvMap().remove("REFRESH");
+ }
+ return self();
+ }
+
+ public CyrusContainer withCyrusVersion(String cyrusVersion) {
+ withEnv("CYRUS_VERSION", cyrusVersion);
+ return self();
+ }
+
+ public CyrusContainer withServerName(String serverName) {
+ withEnv("SERVERNAME", serverName);
+ return self();
+ }
+
+ public CyrusContainer withRelayHost(String relayHost) {
+ withEnv("RELAYHOST", relayHost);
+ return self();
+ }
+
+ public CyrusContainer withRelayAuth(String relayAuth) {
+ withEnv("RELAYAUTH", relayAuth);
+ return self();
+ }
+
+ public CyrusContainer withSkipCreateUsers(boolean skipCreateUsers) {
+ if (skipCreateUsers) {
+ withEnv(SKIP_CREATE_USERS_ENV, "1");
+ } else {
+ getEnvMap().remove(SKIP_CREATE_USERS_ENV);
+ }
+ return self();
+ }
+
+ public CyrusContainer withSeedUser(CyrusUser user) {
+ registerSeedUser(validateUser(user));
+ return self();
+ }
+
+ public CyrusContainer withSeedUsers(CyrusUser... users) {
+ if (users == null) {
+ throw new IllegalArgumentException("users must not be null");
+ }
+ for (CyrusUser user : users) {
+ withSeedUser(user);
+ }
+ return self();
+ }
+
+ public CyrusContainer withSeedUsers(Iterable users) {
+ if (users == null) {
+ throw new IllegalArgumentException("users must not be null");
+ }
+ for (CyrusUser user : users) {
+ withSeedUser(user);
+ }
+ return self();
+ }
+
+ public CyrusContainer withSeedEmptyUser(String userId) {
+ return withSeedUser(CyrusUser.builder(userId).build());
+ }
+
+ public String exportUser(String userId) throws IOException {
+ return executeManagementRequest("GET", userId, null);
+ }
+
+ public Optional exportUserIfExists(String userId) throws IOException {
+ ManagementResponse response = executeManagementRequestRaw("GET", userId, null);
+ if (response.getStatusCode() == HttpURLConnection.HTTP_NOT_FOUND) {
+ return Optional.empty();
+ }
+ assertSuccess(response);
+ return Optional.of(response.getBody());
+ }
+
+ public void importUser(String userId, String jsonPayload) throws IOException {
+ if (jsonPayload == null) {
+ throw new IllegalArgumentException("jsonPayload must not be null");
+ }
+ executeManagementRequest("PUT", userId, jsonPayload);
+ }
+
+ public void upsertUser(CyrusUser user) throws IOException {
+ CyrusUser validatedUser = validateUser(user);
+ importUser(validatedUser.getUserId(), validatedUser.toJson());
+ }
+
+ public boolean createUserIfMissing(CyrusUser user) throws IOException {
+ CyrusUser validatedUser = validateUser(user);
+ if (userExists(validatedUser.getUserId())) {
+ return false;
+ }
+ upsertUser(validatedUser);
+ return true;
+ }
+
+ public boolean userExists(String userId) throws IOException {
+ ManagementResponse response = executeManagementRequestRaw("GET", userId, null);
+ if (response.getStatusCode() == HttpURLConnection.HTTP_NOT_FOUND) {
+ return false;
+ }
+ assertSuccess(response);
+ return true;
+ }
+
+ public void deleteUser(String userId) throws IOException {
+ ManagementResponse existingUser = executeManagementRequestRaw("GET", userId, null);
+ if (existingUser.getStatusCode() == HttpURLConnection.HTTP_NOT_FOUND) {
+ throw createRequestFailedException(
+ "DELETE",
+ existingUser.getUrl(),
+ HttpURLConnection.HTTP_NOT_FOUND,
+ existingUser.getBody()
+ );
+ }
+ assertSuccess(existingUser);
+ executeManagementRequest("DELETE", userId, null);
+ }
+
+ public boolean deleteUserIfExists(String userId) throws IOException {
+ ManagementResponse existingUser = executeManagementRequestRaw("GET", userId, null);
+ if (existingUser.getStatusCode() == HttpURLConnection.HTTP_NOT_FOUND) {
+ return false;
+ }
+ assertSuccess(existingUser);
+ executeManagementRequest("DELETE", userId, null);
+ return true;
+ }
+
+ @Override
+ protected void containerIsStarted(InspectContainerResponse containerInfo) {
+ if ("1".equals(getEnvMap().get(SKIP_CREATE_USERS_ENV))) {
+ deleteDefaultUsers();
+ }
+ applySeedUsers();
+ }
+
+ private String executeManagementRequest(String method, String userId, String payload) throws IOException {
+ ManagementResponse response = executeManagementRequestRaw(method, userId, payload);
+ assertSuccess(response);
+ return response.getBody();
+ }
+
+ private ManagementResponse executeManagementRequestRaw(
+ String method,
+ String userId,
+ String payload
+ ) throws IOException {
+ String effectiveUserId = normalizeUserId(userId);
+ String url = getManagementUrl() + "/" + encodePathSegment(effectiveUserId);
+ HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
+
+ connection.setRequestMethod(method);
+ connection.setConnectTimeout(MANAGEMENT_REQUEST_TIMEOUT_MILLIS);
+ connection.setReadTimeout(MANAGEMENT_REQUEST_TIMEOUT_MILLIS);
+
+ if (payload != null) {
+ connection.setDoOutput(true);
+ connection.setRequestProperty("Content-Type", "application/json");
+ byte[] payloadBytes = payload.getBytes(StandardCharsets.UTF_8);
+ connection.setRequestProperty("Content-Length", String.valueOf(payloadBytes.length));
+ try (OutputStream outputStream = connection.getOutputStream()) {
+ outputStream.write(payloadBytes);
+ }
+ }
+
+ try {
+ int statusCode = connection.getResponseCode();
+ String responseBody = readBody(statusCode < HttpURLConnection.HTTP_BAD_REQUEST
+ ? connection.getInputStream()
+ : connection.getErrorStream());
+ return new ManagementResponse(method, url, statusCode, responseBody);
+ } finally {
+ connection.disconnect();
+ }
+ }
+
+ private void assertSuccess(ManagementResponse response) throws IOException {
+ if (response.getStatusCode() >= HttpURLConnection.HTTP_OK &&
+ response.getStatusCode() < HttpURLConnection.HTTP_MULT_CHOICE) {
+ return;
+ }
+ throw createRequestFailedException(response);
+ }
+
+ private IOException createRequestFailedException(ManagementResponse response) {
+ return createRequestFailedException(
+ response.getMethod(),
+ response.getUrl(),
+ response.getStatusCode(),
+ response.getBody()
+ );
+ }
+
+ private IOException createRequestFailedException(String method, String url, int statusCode, String responseBody) {
+ String body = responseBody == null || responseBody.isEmpty() ? "" : responseBody;
+ return new IOException(
+ String.format(
+ "Cyrus management request failed: %s %s returned HTTP %d with body: %s",
+ method,
+ url,
+ statusCode,
+ body
+ )
+ );
+ }
+
+ private void registerSeedUser(CyrusUser user) {
+ seedUsers.remove(user.getUserId());
+ seedUsers.put(user.getUserId(), user);
+ }
+
+ private void applySeedUsers() {
+ for (CyrusUser user : seedUsers.values()) {
+ try {
+ upsertUser(user);
+ } catch (IOException e) {
+ throw new IllegalStateException("Unable to seed Cyrus user '" + user.getUserId() + "'", e);
+ }
+ }
+ }
+
+ private void deleteDefaultUsers() {
+ for (String user : DEFAULT_USERS) {
+ try {
+ deleteUserIfExists(user);
+ } catch (IOException e) {
+ throw new IllegalStateException("Unable to delete default Cyrus user '" + user + "'", e);
+ }
+ }
+ }
+
+ private static CyrusUser validateUser(CyrusUser user) {
+ if (user == null) {
+ throw new IllegalArgumentException("user must not be null");
+ }
+ return user;
+ }
+
+ private static String normalizeUserId(String userId) {
+ if (userId == null || userId.trim().isEmpty()) {
+ throw new IllegalArgumentException("userId must not be null or blank");
+ }
+ return userId.trim();
+ }
+
+ private static String encodePathSegment(String value) throws IOException {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20");
+ }
+
+ private static String readBody(InputStream inputStream) throws IOException {
+ if (inputStream == null) {
+ return "";
+ }
+
+ try (InputStream in = inputStream; ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ byte[] buffer = new byte[1024];
+ int bytesRead;
+ while ((bytesRead = in.read(buffer)) != -1) {
+ output.write(buffer, 0, bytesRead);
+ }
+ return output.toString(StandardCharsets.UTF_8.name());
+ }
+ }
+
+ private static final class ManagementResponse {
+
+ private final String method;
+
+ private final String url;
+
+ private final int statusCode;
+
+ private final String body;
+
+ private ManagementResponse(String method, String url, int statusCode, String body) {
+ this.method = method;
+ this.url = url;
+ this.statusCode = statusCode;
+ this.body = body;
+ }
+
+ private String getMethod() {
+ return method;
+ }
+
+ private String getUrl() {
+ return url;
+ }
+
+ private int getStatusCode() {
+ return statusCode;
+ }
+
+ private String getBody() {
+ return body;
+ }
+ }
+}
diff --git a/modules/cyrus/src/main/java/org/testcontainers/cyrus/CyrusUser.java b/modules/cyrus/src/main/java/org/testcontainers/cyrus/CyrusUser.java
new file mode 100644
index 00000000000..4adedb4f95d
--- /dev/null
+++ b/modules/cyrus/src/main/java/org/testcontainers/cyrus/CyrusUser.java
@@ -0,0 +1,174 @@
+package org.testcontainers.cyrus;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Represents a Cyrus user payload that can be imported through the management API.
+ */
+public final class CyrusUser {
+
+ private final String userId;
+
+ private final List mailboxes;
+
+ private CyrusUser(String userId, List mailboxes) {
+ this.userId = userId;
+ this.mailboxes = Collections.unmodifiableList(new ArrayList(mailboxes));
+ }
+
+ public static Builder builder(String userId) {
+ return new Builder(normalizeUserId(userId));
+ }
+
+ public String getUserId() {
+ return userId;
+ }
+
+ public String toJson() {
+ StringBuilder json = new StringBuilder();
+ json.append("{\"mailboxes\":[");
+
+ for (int i = 0; i < mailboxes.size(); i++) {
+ Mailbox mailbox = mailboxes.get(i);
+ if (i > 0) {
+ json.append(',');
+ }
+
+ json.append("{\"name\":\"")
+ .append(escapeJson(mailbox.name))
+ .append("\",\"subscribed\":")
+ .append(mailbox.subscribed);
+
+ if (mailbox.specialUse != null) {
+ json.append(",\"specialUse\":\"")
+ .append(escapeJson(mailbox.specialUse))
+ .append('"');
+ }
+
+ json.append('}');
+ }
+
+ json.append("]}");
+ return json.toString();
+ }
+
+ public static final class Builder {
+
+ private final String userId;
+
+ private final List mailboxes = new ArrayList();
+
+ private Builder(String userId) {
+ this.userId = userId;
+ withDefaultMailboxes();
+ }
+
+ public Builder withDefaultMailboxes() {
+ mailboxes.clear();
+ mailboxes.add(new Mailbox("INBOX", true, null));
+ mailboxes.add(new Mailbox("Archive", true, "\\Archive"));
+ mailboxes.add(new Mailbox("Drafts", true, "\\Drafts"));
+ mailboxes.add(new Mailbox("Sent", true, "\\Sent"));
+ mailboxes.add(new Mailbox("Spam", true, "\\Junk"));
+ mailboxes.add(new Mailbox("Trash", true, "\\Trash"));
+ return this;
+ }
+
+ public Builder withoutDefaultMailboxes() {
+ mailboxes.clear();
+ return this;
+ }
+
+ public Builder addMailbox(String name) {
+ return addMailbox(name, true, null);
+ }
+
+ public Builder addMailbox(String name, boolean subscribed, String specialUse) {
+ mailboxes.add(new Mailbox(normalizeMailboxName(name), subscribed, normalizeSpecialUse(specialUse)));
+ return this;
+ }
+
+ public CyrusUser build() {
+ return new CyrusUser(userId, mailboxes);
+ }
+ }
+
+ private static final class Mailbox {
+
+ private final String name;
+
+ private final boolean subscribed;
+
+ private final String specialUse;
+
+ private Mailbox(String name, boolean subscribed, String specialUse) {
+ this.name = name;
+ this.subscribed = subscribed;
+ this.specialUse = specialUse;
+ }
+ }
+
+ private static String normalizeUserId(String userId) {
+ if (userId == null || userId.trim().isEmpty()) {
+ throw new IllegalArgumentException("userId must not be null or blank");
+ }
+ return userId.trim();
+ }
+
+ private static String normalizeMailboxName(String mailboxName) {
+ if (mailboxName == null || mailboxName.trim().isEmpty()) {
+ throw new IllegalArgumentException("mailbox name must not be null or blank");
+ }
+ return mailboxName.trim();
+ }
+
+ private static String normalizeSpecialUse(String specialUse) {
+ if (specialUse == null) {
+ return null;
+ }
+ String trimmed = specialUse.trim();
+ if (trimmed.isEmpty()) {
+ throw new IllegalArgumentException("specialUse must not be blank");
+ }
+ return trimmed;
+ }
+
+ private static String escapeJson(String value) {
+ StringBuilder escaped = new StringBuilder();
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i);
+ switch (c) {
+ case '"':
+ escaped.append("\\\"");
+ break;
+ case '\\':
+ escaped.append("\\\\");
+ break;
+ case '\b':
+ escaped.append("\\b");
+ break;
+ case '\f':
+ escaped.append("\\f");
+ break;
+ case '\n':
+ escaped.append("\\n");
+ break;
+ case '\r':
+ escaped.append("\\r");
+ break;
+ case '\t':
+ escaped.append("\\t");
+ break;
+ default:
+ if (c < 0x20) {
+ escaped.append(String.format("\\u%04x", (int) c));
+ } else {
+ escaped.append(c);
+ }
+ }
+ }
+ return escaped.toString();
+ }
+}
diff --git a/modules/cyrus/src/test/java/org/testcontainers/cyrus/CyrusContainerTest.java b/modules/cyrus/src/test/java/org/testcontainers/cyrus/CyrusContainerTest.java
new file mode 100644
index 00000000000..d10fdc4dee3
--- /dev/null
+++ b/modules/cyrus/src/test/java/org/testcontainers/cyrus/CyrusContainerTest.java
@@ -0,0 +1,248 @@
+package org.testcontainers.cyrus;
+
+import jakarta.mail.Folder;
+import jakarta.mail.Session;
+import jakarta.mail.Store;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Optional;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Properties;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class CyrusContainerTest {
+
+ @Test
+ void shouldStartAndExposeAllEndpoints() throws Exception {
+ try ( // container {
+ CyrusContainer cyrus = new CyrusContainer(CyrusTestImages.CYRUS_IMAGE)
+ // }
+ ) {
+ cyrus.start();
+
+ assertThat(cyrus.getImapPort()).isEqualTo(cyrus.getMappedPort(8143));
+ assertThat(cyrus.getPop3Port()).isEqualTo(cyrus.getMappedPort(8110));
+ assertThat(cyrus.getHttpPort()).isEqualTo(cyrus.getMappedPort(8080));
+ assertThat(cyrus.getLmtpPort()).isEqualTo(cyrus.getMappedPort(8024));
+ assertThat(cyrus.getSievePort()).isEqualTo(cyrus.getMappedPort(4190));
+ assertThat(cyrus.getManagementPort()).isEqualTo(cyrus.getMappedPort(8001));
+
+ assertThat(cyrus.getImapUrl()).isEqualTo("imap://" + cyrus.getHost() + ":" + cyrus.getImapPort());
+ assertThat(cyrus.getPop3Url()).isEqualTo("pop3://" + cyrus.getHost() + ":" + cyrus.getPop3Port());
+ assertThat(cyrus.getHttpBaseUrl()).isEqualTo("http://" + cyrus.getHost() + ":" + cyrus.getHttpPort());
+ assertThat(cyrus.getJmapUrl()).isEqualTo(cyrus.getHttpBaseUrl() + "/jmap/");
+ assertThat(cyrus.getManagementUrl()).isEqualTo("http://" + cyrus.getHost() + ":" + cyrus.getManagementPort());
+
+ assertThat(cyrus.getLivenessCheckPortNumbers())
+ .containsExactlyInAnyOrder(
+ cyrus.getMappedPort(8143),
+ cyrus.getMappedPort(8110),
+ cyrus.getMappedPort(8080),
+ cyrus.getMappedPort(8024),
+ cyrus.getMappedPort(4190),
+ cyrus.getMappedPort(8001)
+ );
+
+ String managementRootResponse = get(cyrus.getManagementUrl() + "/");
+ assertThat(managementRootResponse).contains("Basic test server");
+ }
+ }
+
+ @Test
+ void shouldSeedEmptyUserOnStartup() throws Exception {
+ try ( // startupSeeding {
+ CyrusContainer cyrus = new CyrusContainer(CyrusTestImages.CYRUS_IMAGE)
+ .withSkipCreateUsers(true)
+ .withSeedEmptyUser("alice")
+ // }
+ ) {
+ cyrus.start();
+
+ String exportedUser = cyrus.exportUser("alice");
+ assertThat(exportedUser).contains("\"INBOX\"");
+ }
+ }
+
+ @Test
+ void shouldReplaceSeededUserDeterministically() throws Exception {
+ CyrusUser firstDeclaration = CyrusUser
+ .builder("seeded")
+ .withoutDefaultMailboxes()
+ .addMailbox("INBOX")
+ .addMailbox("FirstOnly")
+ .build();
+ CyrusUser lastDeclaration = CyrusUser
+ .builder("seeded")
+ .withoutDefaultMailboxes()
+ .addMailbox("INBOX")
+ .addMailbox("SecondOnly")
+ .build();
+
+ try (
+ CyrusContainer cyrus = new CyrusContainer(CyrusTestImages.CYRUS_IMAGE)
+ .withSkipCreateUsers(true)
+ .withSeedUser(firstDeclaration)
+ .withSeedUser(lastDeclaration)
+ ) {
+ cyrus.start();
+
+ String exportedUser = cyrus.exportUser("seeded");
+ assertThat(exportedUser).contains("SecondOnly");
+ assertThat(exportedUser).doesNotContain("FirstOnly");
+ }
+ }
+
+ @Test
+ void shouldSupportIdempotentOperations() throws Exception {
+ try (CyrusContainer cyrus = new CyrusContainer(CyrusTestImages.CYRUS_IMAGE).withSkipCreateUsers(true)) {
+ cyrus.start();
+
+ CyrusUser idempotentUser = CyrusUser.builder("idempotent").build();
+
+ assertThat(cyrus.userExists("idempotent")).isFalse();
+ assertThat(cyrus.createUserIfMissing(idempotentUser)).isTrue();
+ assertThat(cyrus.createUserIfMissing(idempotentUser)).isFalse();
+ assertThat(cyrus.userExists("idempotent")).isTrue();
+
+ Optional exportedUser = cyrus.exportUserIfExists("idempotent");
+ assertThat(exportedUser).isPresent();
+ assertThat(exportedUser.get()).contains("\"INBOX\"");
+
+ assertThat(cyrus.deleteUserIfExists("idempotent")).isTrue();
+ assertThat(cyrus.deleteUserIfExists("idempotent")).isFalse();
+ assertThat(cyrus.exportUserIfExists("idempotent")).isEmpty();
+ }
+ }
+
+ @Test
+ void shouldKeepStrictOperationsFailFast() throws Exception {
+ try (CyrusContainer cyrus = new CyrusContainer(CyrusTestImages.CYRUS_IMAGE).withSkipCreateUsers(true)) {
+ cyrus.start();
+
+ assertThatThrownBy(() -> cyrus.exportUser("missing-user"))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("HTTP 404");
+ assertThatThrownBy(() -> cyrus.deleteUser("missing-user"))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("HTTP 404");
+ }
+ }
+
+ @Test
+ void shouldRemoveDefaultUsersWhenSkipCreateUsersEnabled() throws Exception {
+ try (CyrusContainer cyrus = new CyrusContainer(CyrusTestImages.CYRUS_IMAGE).withSkipCreateUsers(true)) {
+ cyrus.start();
+
+ assertThat(cyrus.userExists("user1")).isFalse();
+ assertThatThrownBy(() -> cyrus.exportUser("user1")).isInstanceOf(IOException.class).hasMessageContaining("HTTP 404");
+ }
+ }
+
+ @Test
+ void shouldAuthenticateViaImapAfterUserImport() throws Exception {
+ try (CyrusContainer cyrus = new CyrusContainer(CyrusTestImages.CYRUS_IMAGE).withSkipCreateUsers(true)) {
+ cyrus.start();
+ cyrus.upsertUser(CyrusUser.builder("imap-user").build());
+
+ Properties properties = new Properties();
+ properties.setProperty("mail.store.protocol", "imap");
+ properties.setProperty("mail.imap.host", cyrus.getHost());
+ properties.setProperty("mail.imap.port", String.valueOf(cyrus.getImapPort()));
+ properties.setProperty("mail.imap.ssl.enable", "false");
+ properties.setProperty("mail.imap.starttls.enable", "false");
+
+ Session session = Session.getInstance(properties);
+ Store store = session.getStore("imap");
+
+ try {
+ store.connect("imap-user", "x");
+ Folder inbox = store.getFolder("INBOX");
+ inbox.open(Folder.READ_ONLY);
+
+ assertThat(inbox.exists()).isTrue();
+ assertThat(inbox.getMessageCount()).isZero();
+
+ inbox.close(false);
+ } finally {
+ store.close();
+ }
+ }
+ }
+
+ @Test
+ void shouldApplyEnvironmentSetters() throws Exception {
+ try (
+ CyrusContainer cyrus = new CyrusContainer(CyrusTestImages.CYRUS_IMAGE)
+ .withDefaultDomain("testcontainers.org")
+ .withServerName("mail.testcontainers.org")
+ .withRelayHost("smtp.fastmail.com")
+ .withRelayAuth("user:pass")
+ .withSkipCreateUsers(true)
+ ) {
+ assertThat(cyrus.getEnvMap()).containsEntry("SKIP_CREATE_USERS", "1");
+ cyrus.withSkipCreateUsers(false);
+ assertThat(cyrus.getEnvMap()).doesNotContainKey("SKIP_CREATE_USERS");
+ cyrus.withSkipCreateUsers(true);
+
+ cyrus.start();
+
+ assertThat(cyrus.getEnvMap())
+ .containsEntry("DEFAULTDOMAIN", "testcontainers.org")
+ .containsEntry("SERVERNAME", "mail.testcontainers.org")
+ .containsEntry("RELAYHOST", "smtp.fastmail.com")
+ .containsEntry("RELAYAUTH", "user:pass")
+ .containsEntry("SKIP_CREATE_USERS", "1");
+
+ String configOutput = cyrus
+ .execInContainer("sh", "-c", "grep -E '^(defaultdomain|servername):' /etc/imapd.conf")
+ .getStdout();
+ assertThat(configOutput)
+ .contains("defaultdomain: testcontainers.org")
+ .contains("servername: mail.testcontainers.org");
+ }
+ }
+
+ @Test
+ void shouldSetRefreshAndCyrusVersionVariables() {
+ CyrusContainer cyrus = new CyrusContainer(CyrusTestImages.CYRUS_IMAGE).withRefresh(true).withCyrusVersion("main");
+
+ assertThat(cyrus.getEnvMap()).containsEntry("REFRESH", "1").containsEntry("CYRUS_VERSION", "main");
+
+ cyrus.withRefresh(false);
+ assertThat(cyrus.getEnvMap()).doesNotContainKey("REFRESH").containsEntry("CYRUS_VERSION", "main");
+ }
+
+ private static String get(String targetUrl) throws IOException {
+ HttpURLConnection connection = (HttpURLConnection) new URL(targetUrl).openConnection();
+ connection.setRequestMethod("GET");
+ connection.setConnectTimeout(10_000);
+ connection.setReadTimeout(10_000);
+
+ try {
+ int statusCode = connection.getResponseCode();
+ String responseBody = read(connection.getInputStream());
+ assertThat(statusCode).isEqualTo(HttpURLConnection.HTTP_OK);
+ return responseBody;
+ } finally {
+ connection.disconnect();
+ }
+ }
+
+ private static String read(InputStream inputStream) throws IOException {
+ StringBuilder output = new StringBuilder();
+ try (InputStream in = inputStream) {
+ byte[] buffer = new byte[1024];
+ int bytesRead;
+ while ((bytesRead = in.read(buffer)) != -1) {
+ output.append(new String(buffer, 0, bytesRead, StandardCharsets.UTF_8));
+ }
+ return output.toString();
+ }
+ }
+}
diff --git a/modules/cyrus/src/test/java/org/testcontainers/cyrus/CyrusTestImages.java b/modules/cyrus/src/test/java/org/testcontainers/cyrus/CyrusTestImages.java
new file mode 100644
index 00000000000..ecd4f8d2490
--- /dev/null
+++ b/modules/cyrus/src/test/java/org/testcontainers/cyrus/CyrusTestImages.java
@@ -0,0 +1,10 @@
+package org.testcontainers.cyrus;
+
+import org.testcontainers.utility.DockerImageName;
+
+public interface CyrusTestImages {
+ String CYRUS_IMAGE_NAME =
+ "ghcr.io/cyrusimap/cyrus-docker-test-server@sha256:d639a9116691a7a1c875073486c419d60843e5ef8e32e65c5ef56283874dbf2c";
+
+ DockerImageName CYRUS_IMAGE = DockerImageName.parse(CYRUS_IMAGE_NAME);
+}
diff --git a/modules/cyrus/src/test/java/org/testcontainers/cyrus/CyrusUserTest.java b/modules/cyrus/src/test/java/org/testcontainers/cyrus/CyrusUserTest.java
new file mode 100644
index 00000000000..6acedcf773f
--- /dev/null
+++ b/modules/cyrus/src/test/java/org/testcontainers/cyrus/CyrusUserTest.java
@@ -0,0 +1,69 @@
+package org.testcontainers.cyrus;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class CyrusUserTest {
+
+ private static final String DEFAULT_EMPTY_USER_JSON =
+ "{\"mailboxes\":[{\"name\":\"INBOX\",\"subscribed\":true}," +
+ "{\"name\":\"Archive\",\"subscribed\":true,\"specialUse\":\"\\\\Archive\"}," +
+ "{\"name\":\"Drafts\",\"subscribed\":true,\"specialUse\":\"\\\\Drafts\"}," +
+ "{\"name\":\"Sent\",\"subscribed\":true,\"specialUse\":\"\\\\Sent\"}," +
+ "{\"name\":\"Spam\",\"subscribed\":true,\"specialUse\":\"\\\\Junk\"}," +
+ "{\"name\":\"Trash\",\"subscribed\":true,\"specialUse\":\"\\\\Trash\"}]}";
+
+ @Test
+ void builderShouldDefaultToEmptyUserMailboxes() {
+ // userBuilder {
+ CyrusUser user = CyrusUser.builder("alice").build();
+ // }
+
+ assertThat(user.getUserId()).isEqualTo("alice");
+ assertThat(user.toJson()).isEqualTo(DEFAULT_EMPTY_USER_JSON);
+ }
+
+ @Test
+ void builderShouldAllowCustomMailboxList() {
+ CyrusUser user = CyrusUser
+ .builder("bob")
+ .withoutDefaultMailboxes()
+ .addMailbox("INBOX")
+ .addMailbox("Projects", false, "\\Archive")
+ .build();
+
+ assertThat(user.toJson()).isEqualTo(
+ "{\"mailboxes\":[{\"name\":\"INBOX\",\"subscribed\":true}," +
+ "{\"name\":\"Projects\",\"subscribed\":false,\"specialUse\":\"\\\\Archive\"}]}"
+ );
+ }
+
+ @Test
+ void builderShouldValidateUserId() {
+ assertThatThrownBy(() -> CyrusUser.builder(null)).isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> CyrusUser.builder(" ")).isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void builderShouldProduceDeterministicJsonOrder() {
+ CyrusUser first = CyrusUser
+ .builder("order")
+ .withoutDefaultMailboxes()
+ .addMailbox("INBOX")
+ .addMailbox("A")
+ .addMailbox("B")
+ .build();
+ CyrusUser second = CyrusUser
+ .builder("order")
+ .withoutDefaultMailboxes()
+ .addMailbox("INBOX")
+ .addMailbox("A")
+ .addMailbox("B")
+ .build();
+
+ assertThat(first.toJson()).isEqualTo(second.toJson());
+ assertThat(first.toJson().indexOf("\"A\"")).isLessThan(first.toJson().indexOf("\"B\""));
+ }
+}
diff --git a/modules/cyrus/src/test/resources/cyrus/empty.json b/modules/cyrus/src/test/resources/cyrus/empty.json
new file mode 100644
index 00000000000..e49f04811e5
--- /dev/null
+++ b/modules/cyrus/src/test/resources/cyrus/empty.json
@@ -0,0 +1,33 @@
+{
+ "mailboxes" : [
+ {
+ "name" : "INBOX",
+ "subscribed" : true
+ },
+ {
+ "name" : "Archive",
+ "subscribed" : true,
+ "specialUse" : "\\Archive"
+ },
+ {
+ "name" : "Drafts",
+ "subscribed" : true,
+ "specialUse" : "\\Drafts"
+ },
+ {
+ "name" : "Sent",
+ "subscribed" : true,
+ "specialUse" : "\\Sent"
+ },
+ {
+ "name" : "Spam",
+ "subscribed" : true,
+ "specialUse" : "\\Junk"
+ },
+ {
+ "name" : "Trash",
+ "subscribed" : true,
+ "specialUse" : "\\Trash"
+ }
+ ]
+}
diff --git a/modules/cyrus/src/test/resources/logback-test.xml b/modules/cyrus/src/test/resources/logback-test.xml
new file mode 100644
index 00000000000..83ef7a1a3ef
--- /dev/null
+++ b/modules/cyrus/src/test/resources/logback-test.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+ %d{HH:mm:ss.SSS} %-5level %logger - %msg%n
+
+
+
+
+
+
+
+
+