Fix JMAP: */changes methods leak ids of non-shared objects

This commit is contained in:
Maurus Decimus
2026-06-10 13:51:36 +02:00
parent 26f41f8aa7
commit 33f288d0ea
36 changed files with 1475 additions and 190 deletions

View File

@@ -1,6 +1,6 @@
[package]
name = "tests"
version = "0.16.8"
version = "0.16.9"
edition = "2024"
[features]

View File

@@ -0,0 +1,627 @@
import argparse
import math
import multiprocessing
import os
import queue
import random
import re
import shutil
import smtplib
import ssl
import sys
import tempfile
import threading
import time
from email.utils import formatdate, make_msgid
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 25
DEFAULT_THREADS = 5
DEFAULT_PROCESSES = 1
DEFAULT_MESSAGES = 100
DEFAULT_MIN_SIZE = 1024
DEFAULT_MAX_SIZE = 51200
DEFAULT_POOL_SIZE = 64
DEFAULT_SENDER = "stress-test@example.com"
DEFAULT_USERS_FILE = "users.txt"
DEFAULT_DICTIONARY = "/usr/share/dict/words"
DEFAULT_TIMEOUT = 60
LINE_WIDTH = 72
FALLBACK_WORDS = (
"lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod "
"tempor incididunt ut labore et dolore magna aliqua enim ad minim veniam "
"quis nostrud exercitation ullamco laboris nisi aliquip ex ea commodo"
).split()
WORDS = FALLBACK_WORDS
DOT_LINE = re.compile(br"(?m)^\.")
STOP_EVENT = threading.Event()
class SmtpError(Exception):
pass
class AsyncLogger:
def __init__(self, enabled):
self.enabled = enabled
self._queue = queue.Queue() if enabled else None
self._thread = None
def start(self):
if not self.enabled:
return
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def log(self, message):
if self.enabled:
self._queue.put(message)
def _run(self):
while True:
message = self._queue.get()
if message is None:
break
print(message, file=sys.stderr, flush=True)
def stop(self):
if not self.enabled:
return
self._queue.put(None)
if self._thread is not None:
self._thread.join()
def load_words(path):
try:
with open(path, "r", encoding="utf-8", errors="ignore") as file:
words = [w.strip() for w in file if w.strip().isalpha()]
except OSError:
words = []
if not words:
print(
f"WARNING: word list '{path}' not found or empty; "
f"falling back to built-in lorem ipsum words. "
f"Override with --dict <path>.",
file=sys.stderr,
)
return list(FALLBACK_WORDS)
return words
def random_subject():
return " ".join(random.choices(WORDS, k=random.randint(3, 10)))
def random_body(target_size):
lines = []
total = 0
line = ""
while total < target_size:
word = random.choice(WORDS)
if line and len(line) + 1 + len(word) > LINE_WIDTH:
lines.append(line)
total += len(line) + 2
line = word
elif line:
line = f"{line} {word}"
else:
line = word
if line:
lines.append(line)
return "\r\n".join(lines) + "\r\n"
def quote_periods(data):
return DOT_LINE.sub(b"..", data)
def build_headers(sender, recipient):
return (
f"From: {sender}\r\n"
f"To: {recipient}\r\n"
f"Subject: {random_subject()}\r\n"
f"Date: {formatdate(localtime=True)}\r\n"
f"Message-ID: {make_msgid(domain='stress.test')}\r\n"
f"MIME-Version: 1.0\r\n"
f"Content-Type: text/plain; charset=us-ascii\r\n"
f"\r\n"
).encode("ascii", "replace")
def build_body(size):
body = quote_periods(random_body(size).encode("ascii", "replace"))
if not body.endswith(b"\r\n"):
body += b"\r\n"
return body
class MemoryStore:
backend = "memory"
def __init__(self):
self._items = []
def add(self, data):
self._items.append(data)
def get(self, index):
return self._items[index]
def __len__(self):
return len(self._items)
def cleanup(self):
self._items = []
class DiskStore:
def __init__(self, root):
self._dir = tempfile.mkdtemp(prefix="smtp_stress_", dir=root)
self.backend = self._dir
self._paths = []
def add(self, data):
path = os.path.join(self._dir, f"msg_{len(self._paths):09d}.eml")
with open(path, "wb") as handle:
handle.write(data)
self._paths.append(path)
def get(self, index):
with open(self._paths[index], "rb") as handle:
return handle.read()
def __len__(self):
return len(self._paths)
def cleanup(self):
shutil.rmtree(self._dir, ignore_errors=True)
def build_body_store(ctx):
count = min(ctx.pool_size, ctx.messages)
if ctx.spool_dir is not None:
store = DiskStore(ctx.spool_dir)
else:
store = MemoryStore()
for _ in range(count):
if ctx.fixed_size is not None:
size = ctx.fixed_size
else:
size = random.randint(ctx.min_size, ctx.max_size)
store.add(build_body(size))
return store
def make_tls_context():
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context
class Stats:
def __init__(self):
self._lock = threading.Lock()
self.latencies = []
self.errors = 0
self.bytes = 0
def record(self, latency_ms, num_bytes):
with self._lock:
self.latencies.append(latency_ms)
self.bytes += num_bytes
def record_error(self):
with self._lock:
self.errors += 1
def snapshot(self):
with self._lock:
return list(self.latencies), self.errors, self.bytes
class Counter:
def __init__(self, total):
self._lock = threading.Lock()
self._remaining = total
def claim(self):
with self._lock:
if self._remaining <= 0:
return False
self._remaining -= 1
return True
def read_recipients(file_path):
recipients = []
try:
with open(file_path, "r") as file:
for line in file:
line = line.strip()
if not line:
continue
recipients.append(line.split(":", 1)[0])
except OSError as e:
raise SystemExit(f"Could not read recipients from '{file_path}': {e}")
if not recipients:
raise SystemExit(f"No recipients found in '{file_path}'.")
return recipients
def connect(ctx):
server = smtplib.SMTP(ctx.host, ctx.port, timeout=ctx.timeout)
server.ehlo()
if ctx.starttls:
if not server.has_extn("starttls"):
server.quit()
raise SmtpError("server does not advertise STARTTLS")
server.starttls(context=ctx.tls_context)
server.ehlo()
return server
def send_one(server, sender, recipient, header, body):
code, resp = server.mail(sender)
if code != 250:
server.rset()
raise SmtpError(f"MAIL FROM rejected: {code} {resp!r}")
code, resp = server.rcpt(recipient)
if code not in (250, 251):
server.rset()
raise SmtpError(f"RCPT TO rejected: {code} {resp!r}")
code, resp = server.docmd("DATA")
if code != 354:
raise SmtpError(f"DATA rejected: {code} {resp!r}")
server.send(header)
server.send(body)
start = time.monotonic()
server.send(b".\r\n")
code, resp = server.getreply()
elapsed_ms = (time.monotonic() - start) * 1000
if code != 250:
raise SmtpError(f"message rejected: {code} {resp!r}")
return elapsed_ms
def worker(ctx, counter, recipients, stats, store, logger):
pool_len = len(store)
while not STOP_EVENT.is_set() and counter.claim():
server = None
try:
server = connect(ctx)
recipient = random.choice(recipients)
body = store.get(random.randrange(pool_len))
header = build_headers(ctx.sender, recipient)
elapsed_ms = send_one(server, ctx.sender, recipient, header, body)
num_bytes = len(header) + len(body)
stats.record(elapsed_ms, num_bytes)
if logger.enabled:
logger.log(f"OK {elapsed_ms:9.2f}ms {num_bytes:>9}B -> {recipient}")
except (SmtpError, smtplib.SMTPException, OSError) as e:
stats.record_error()
if logger.enabled:
logger.log(f"ERR {e}")
finally:
if server is not None:
try:
server.quit()
except Exception:
try:
server.close()
except Exception:
pass
def percentile(sorted_values, pct):
if not sorted_values:
return 0.0
if len(sorted_values) == 1:
return sorted_values[0]
rank = (len(sorted_values) - 1) * (pct / 100.0)
low = math.floor(rank)
high = math.ceil(rank)
if low == high:
return sorted_values[int(rank)]
return sorted_values[low] * (high - rank) + sorted_values[high] * (rank - low)
def stddev(values, mean):
if len(values) < 2:
return 0.0
variance = sum((v - mean) ** 2 for v in values) / (len(values) - 1)
return math.sqrt(variance)
def print_report(
latencies,
errors,
total_bytes,
send_seconds,
gen_seconds,
pool_count,
workers,
storage,
report_header=None,
):
count = len(latencies)
mb = total_bytes / (1024 * 1024)
throughput = count / send_seconds if send_seconds > 0 else 0.0
mb_per_sec = mb / send_seconds if send_seconds > 0 else 0.0
line = "-" * 60
print()
if report_header:
print(report_header)
print(line)
print("SMTP ingestion stress test report")
print(line)
print(f"{'Workers':<26}{workers}")
print(f"{'Message store':<26}{storage}")
print(f"{'Messages OK':<26}{count}")
print(f"{'Messages failed':<26}{errors}")
print(f"{'Bodies pregenerated':<26}{pool_count}")
print(f"{'Pool gen time (s)':<26}{gen_seconds:.2f}")
print(f"{'Send wall time (s)':<26}{send_seconds:.2f}")
print(f"{'Throughput (msg/s)':<26}{throughput:.2f}")
print(f"{'Data sent (MB)':<26}{mb:.2f}")
print(f"{'Data rate (MB/s)':<26}{mb_per_sec:.2f}")
print(line)
print("Ingestion time (DATA terminator to server OK), milliseconds")
print(line)
if count:
ordered = sorted(latencies)
mean = sum(ordered) / count
rows = [
("min", ordered[0]),
("max", ordered[-1]),
("avg", mean),
("median", percentile(ordered, 50)),
("p95", percentile(ordered, 95)),
("p99", percentile(ordered, 99)),
("stddev", stddev(ordered, mean)),
]
for name, value in rows:
print(f"{name:<22}{value:.2f}")
else:
print("no messages were ingested")
print(line)
sys.stdout.flush()
class Context:
def __init__(self, args, messages):
self.host = args.host
self.port = args.port
self.threads = args.threads
self.sender = args.sender
self.starttls = not args.no_starttls
self.timeout = args.timeout
self.min_size = args.min_size
self.max_size = args.max_size
self.fixed_size = args.size
self.pool_size = args.pool_size
self.spool_dir = args.spool_dir
self.messages = messages
self.tls_context = make_tls_context() if self.starttls else None
def run_threads(ctx, recipients, message_count, store, logger):
stats = Stats()
counter = Counter(message_count)
threads = [
threading.Thread(
target=worker,
args=(ctx, counter, recipients, stats, store, logger),
daemon=True,
)
for _ in range(ctx.threads)
]
for thread in threads:
thread.start()
try:
while any(t.is_alive() for t in threads):
for t in threads:
t.join(timeout=0.2)
except KeyboardInterrupt:
logger.log("Stopping...")
STOP_EVENT.set()
for t in threads:
t.join()
return stats
def child_main(args, recipients, message_count, barrier, result_queue):
global WORDS
WORDS = load_words(args.dict)
ctx = Context(args, message_count)
logger = AsyncLogger(not args.quiet)
store = build_body_store(ctx)
try:
logger.start()
try:
barrier.wait()
except threading.BrokenBarrierError:
result_queue.put(([], 0, 0, len(store)))
return
stats = run_threads(ctx, recipients, message_count, store, logger)
logger.stop()
latencies, errors, total_bytes = stats.snapshot()
result_queue.put((latencies, errors, total_bytes, len(store)))
finally:
store.cleanup()
def distribute(total, parts):
base, remainder = divmod(total, parts)
return [base + (1 if i < remainder else 0) for i in range(parts)]
def parse_args():
parser = argparse.ArgumentParser(
description="Concurrent SMTP ingestion stress test over port 25 with STARTTLS."
)
parser.add_argument("--host", default=DEFAULT_HOST)
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
parser.add_argument("--threads", type=int, default=DEFAULT_THREADS)
parser.add_argument(
"--processes",
type=int,
default=DEFAULT_PROCESSES,
help="Worker processes to spawn (each runs --threads threads). Scales past the GIL.",
)
parser.add_argument(
"--messages",
type=int,
default=DEFAULT_MESSAGES,
help="Total messages to send, distributed across the threads.",
)
parser.add_argument("--sender", default=DEFAULT_SENDER, help="Envelope MAIL FROM address.")
parser.add_argument("--users-file", default=DEFAULT_USERS_FILE)
parser.add_argument(
"--size",
type=int,
help="Fixed message body size in bytes; overrides --min-size/--max-size.",
)
parser.add_argument("--min-size", type=int, default=DEFAULT_MIN_SIZE)
parser.add_argument("--max-size", type=int, default=DEFAULT_MAX_SIZE)
parser.add_argument(
"--pool-size",
type=int,
default=DEFAULT_POOL_SIZE,
help="Distinct message bodies pregenerated before timing (reused at random). "
"Each sent message gets a fresh unique Message-ID regardless of this.",
)
parser.add_argument(
"--spool-dir",
nargs="?",
const=tempfile.gettempdir(),
default=None,
help="Spool pregenerated messages to disk instead of memory. "
"With no value uses the system temp dir; pass a path to override.",
)
parser.add_argument("--dict", default=DEFAULT_DICTIONARY)
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
parser.add_argument(
"--header",
help="Optional header line printed at the top of the final report on stdout.",
)
parser.add_argument(
"--no-starttls",
action="store_true",
help="Send over plaintext instead of upgrading with STARTTLS.",
)
parser.add_argument("--quiet", action="store_true")
args = parser.parse_args()
if args.threads < 1:
parser.error("--threads must be at least 1")
if args.processes < 1:
parser.error("--processes must be at least 1")
if args.messages < 1:
parser.error("--messages must be at least 1")
if args.pool_size < 1:
parser.error("--pool-size must be at least 1")
if args.size is None and args.min_size > args.max_size:
parser.error("--min-size must not exceed --max-size")
return args
def run_single_process(args, recipients):
global WORDS
WORDS = load_words(args.dict)
ctx = Context(args, args.messages)
logger = AsyncLogger(not args.quiet)
gen_start = time.monotonic()
store = build_body_store(ctx)
gen_seconds = time.monotonic() - gen_start
try:
logger.start()
start = time.monotonic()
stats = run_threads(ctx, recipients, args.messages, store, logger)
send_seconds = time.monotonic() - start
logger.stop()
latencies, errors, total_bytes = stats.snapshot()
workers = f"1 process x {args.threads} threads"
print_report(
latencies, errors, total_bytes, send_seconds, gen_seconds, len(store),
workers, store.backend, args.header,
)
finally:
store.cleanup()
def run_multi_process(args, recipients):
nproc = min(args.processes, args.messages)
shares = distribute(args.messages, nproc)
barrier = multiprocessing.Barrier(nproc + 1)
result_queue = multiprocessing.Queue()
procs = []
for share in shares:
proc = multiprocessing.Process(
target=child_main,
args=(args, recipients, share, barrier, result_queue),
daemon=False,
)
proc.start()
procs.append(proc)
gen_start = time.monotonic()
interrupted = False
try:
barrier.wait()
except KeyboardInterrupt:
interrupted = True
barrier.abort()
gen_seconds = time.monotonic() - gen_start
start = time.monotonic()
results = []
try:
for _ in procs:
results.append(result_queue.get())
except KeyboardInterrupt:
interrupted = True
for proc in procs:
proc.terminate()
send_seconds = time.monotonic() - start
for proc in procs:
proc.join()
latencies = []
errors = 0
total_bytes = 0
pool_count = 0
for lat, err, nbytes, pool_len in results:
latencies.extend(lat)
errors += err
total_bytes += nbytes
pool_count += pool_len
if interrupted:
print("Interrupted.", file=sys.stderr, flush=True)
workers = f"{nproc} processes x {args.threads} threads"
storage = "memory" if args.spool_dir is None else f"disk ({args.spool_dir})"
print_report(
latencies, errors, total_bytes, send_seconds, gen_seconds, pool_count,
workers, storage, args.header,
)
def main():
args = parse_args()
recipients = read_recipients(args.users_file)
if args.processes == 1:
run_single_process(args, recipients)
else:
run_multi_process(args, recipients)
if __name__ == "__main__":
main()

View File

@@ -1,132 +1,490 @@
import smtplib
import argparse
import imaplib
import math
import os
import random
import smtplib
import ssl
import threading
import random
import time
import string
from collections import defaultdict
from email.mime.text import MIMEText
smtp_server = "127.0.0.1"
smtp_port = 465
imap_server = "127.0.0.1"
imap_port = 993
num_threads = 5
runs = 10 # Set to None for infinite loop
DEFAULT_SMTP_SERVER = "127.0.0.1"
DEFAULT_SMTP_PORT = 465
DEFAULT_IMAP_SERVER = "127.0.0.1"
DEFAULT_IMAP_PORT = 993
DEFAULT_THREADS = 5
DEFAULT_RUNS = 10
DEFAULT_DICTIONARY = "/usr/share/dict/words"
FALLBACK_WORDS = (
"lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod "
"tempor incididunt ut labore et dolore magna aliqua enim ad minim veniam "
"quis nostrud exercitation ullamco laboris nisi aliquip ex ea commodo"
).split()
WORDS = FALLBACK_WORDS
SMTP_SEND = "SMTP SEND"
IMAP_APPEND = "IMAP APPEND"
IMAP_FETCH = "IMAP FETCH"
IMAP_DELETE = "IMAP DELETE"
ACTIONS = (SMTP_SEND, IMAP_APPEND, IMAP_FETCH, IMAP_DELETE)
class Stats:
def __init__(self):
self._lock = threading.Lock()
self._latencies = defaultdict(list)
self._errors = defaultdict(int)
self._skips = defaultdict(int)
self._bytes = defaultdict(int)
def record(self, action, latency_ms, num_bytes=0):
with self._lock:
self._latencies[action].append(latency_ms)
self._bytes[action] += num_bytes
def record_error(self, action):
with self._lock:
self._errors[action] += 1
def record_skip(self, action):
with self._lock:
self._skips[action] += 1
def snapshot(self):
with self._lock:
return (
{k: list(v) for k, v in self._latencies.items()},
dict(self._errors),
dict(self._skips),
dict(self._bytes),
)
PRINT_LOCK = threading.Lock()
STOP_EVENT = threading.Event()
def read_credentials(file_path):
if not os.path.exists(file_path):
raise SystemExit(
f"Credentials file '{file_path}' not found. "
f"Run stress_test_prepare.py first to create users."
)
credentials = []
with open(file_path, "r") as file:
credentials = [line.strip().split(':') for line in file if line.strip()]
for line in file:
line = line.strip()
if not line:
continue
parts = line.split(":", 1)
if len(parts) != 2:
continue
credentials.append((parts[0], parts[1]))
if not credentials:
raise SystemExit(f"No valid credentials found in '{file_path}'.")
return credentials
def allow_invalid_certificates():
# Create an SSL context
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
context.verify_mode = ssl.CERT_NONE
return context
def generate_random_string(min_size, max_size):
"""Generates a random string of a size between min_size and max_size."""
size = random.randint(min_size, max_size)
chars = string.ascii_letters + string.digits + ' '
return ''.join(random.choice(chars) for _ in range(size))
def generate_email(username, recipient):
"""Generate random subject and content for email."""
subject = generate_random_string(10, 100) # Random subject between 10 and 100 characters
content_size = random.randint(100, 1048576) # Random content size between 100 bytes and ~1MB
content = generate_random_string(content_size, content_size)
def load_words(path):
try:
with open(path, "r", encoding="utf-8", errors="ignore") as file:
words = [w.strip() for w in file if w.strip().isalpha()]
except OSError:
words = []
if not words:
print(
f"WARNING: word list '{path}' not found or empty; "
f"falling back to built-in lorem ipsum words. "
f"Override with --dict <path>."
)
return list(FALLBACK_WORDS)
return words
def random_words(min_size, max_size):
target = random.randint(min_size, max_size)
parts = []
length = 0
while length < target:
for word in random.choices(WORDS, k=64):
parts.append(word)
length += len(word) + 1
if length >= target:
break
return " ".join(parts)
def generate_email(username, recipient, max_content_size):
subject = random_words(10, 100)
content = random_words(100, max_content_size)
message = MIMEText(content)
message['Subject'] = subject
message['From'] = username
message['To'] = recipient
message["Subject"] = subject
message["From"] = username
message["To"] = recipient
return message.as_string()
def smtp_send_message(username, password, recipient):
def log_ok(stats, action, latency_ms, detail="", num_bytes=0, verbose=True):
stats.record(action, latency_ms, num_bytes)
if verbose:
with PRINT_LOCK:
print(f"OK {latency_ms:9.2f}ms {action} {detail}")
def log_err(stats, action, error, verbose=True):
stats.record_error(action)
if verbose:
with PRINT_LOCK:
print(f"ERR {action} {error}")
def smtp_send_message(ctx, username, password, recipient):
try:
with smtplib.SMTP_SSL(smtp_server, smtp_port, context=allow_invalid_certificates()) as server:
with smtplib.SMTP_SSL(
ctx.smtp_server, ctx.smtp_port, context=allow_invalid_certificates()
) as server:
server.login(username, password)
start_time = time.time()
server.sendmail(username, recipient, generate_email(username, recipient))
elapsed_time_ms = (time.time() - start_time) * 1000
print(f"OK {elapsed_time_ms} SMTP {username} -> {recipient}")
payload = generate_email(username, recipient, ctx.max_content_size)
start_time = time.monotonic()
server.sendmail(username, recipient, payload)
elapsed_ms = (time.monotonic() - start_time) * 1000
log_ok(
ctx.stats,
SMTP_SEND,
elapsed_ms,
f"{username} -> {recipient}",
len(payload),
ctx.verbose,
)
except Exception as e:
print(f"ERR SMTP {e}")
log_err(ctx.stats, SMTP_SEND, e, ctx.verbose)
def imap_append_message(username, password, recipient):
def imap_append_message(ctx, username, password, recipient):
try:
with imaplib.IMAP4_SSL(imap_server, imap_port, ssl_context=allow_invalid_certificates()) as imap:
with imaplib.IMAP4_SSL(
ctx.imap_server, ctx.imap_port, ssl_context=allow_invalid_certificates()
) as imap:
imap.login(username, password)
start_time = time.time()
imap.append('INBOX', None, imaplib.Time2Internaldate(time.time()), generate_email(username, recipient).encode('utf-8'))
elapsed_time_ms = (time.time() - start_time) * 1000
print(f"OK {elapsed_time_ms} IMAP APPEND {username}")
payload = generate_email(username, recipient, ctx.max_content_size).encode("utf-8")
start_time = time.monotonic()
imap.append("INBOX", None, imaplib.Time2Internaldate(time.time()), payload)
elapsed_ms = (time.monotonic() - start_time) * 1000
log_ok(ctx.stats, IMAP_APPEND, elapsed_ms, username, len(payload), ctx.verbose)
except Exception as e:
print(f"ERR IMAP {e}")
log_err(ctx.stats, IMAP_APPEND, e, ctx.verbose)
def imap_list_fetch(username, password):
def imap_list_fetch(ctx, username, password, recipient):
try:
with imaplib.IMAP4_SSL(imap_server, imap_port, ssl_context=allow_invalid_certificates()) as imap:
with imaplib.IMAP4_SSL(
ctx.imap_server, ctx.imap_port, ssl_context=allow_invalid_certificates()
) as imap:
imap.login(username, password)
imap.select('INBOX')
start_time = time.time()
typ, data = imap.search(None, 'ALL')
if data[0]:
imap.select("INBOX")
start_time = time.monotonic()
typ, data = imap.search(None, "ALL")
if data and data[0]:
messages = data[0].split()
random_msg_num = random.choice(messages)
typ, msg_data = imap.fetch(random_msg_num, '(RFC822)')
elapsed_time_ms = (time.time() - start_time) * 1000
print(f"OK {elapsed_time_ms} IMAP FETCH {username} {random_msg_num}")
imap.fetch(random_msg_num, "(RFC822)")
elapsed_ms = (time.monotonic() - start_time) * 1000
log_ok(
ctx.stats,
IMAP_FETCH,
elapsed_ms,
f"{username} {random_msg_num.decode()}",
verbose=ctx.verbose,
)
else:
ctx.stats.record_skip(IMAP_FETCH)
except Exception as e:
print(f"ERR IMAP {e}")
log_err(ctx.stats, IMAP_FETCH, e, ctx.verbose)
def imap_delete_message(username, password):
def imap_delete_message(ctx, username, password, recipient):
try:
with imaplib.IMAP4_SSL(imap_server, imap_port, ssl_context=allow_invalid_certificates()) as imap:
with imaplib.IMAP4_SSL(
ctx.imap_server, ctx.imap_port, ssl_context=allow_invalid_certificates()
) as imap:
imap.login(username, password)
imap.select('INBOX')
start_time = time.time()
typ, data = imap.search(None, 'ALL')
if data[0]:
imap.select("INBOX")
start_time = time.monotonic()
typ, data = imap.search(None, "ALL")
if data and data[0]:
messages = data[0].split()
random_msg_num = random.choice(messages)
imap.store(random_msg_num, '+FLAGS', '\\Deleted')
imap.store(random_msg_num, "+FLAGS", "\\Deleted")
imap.expunge()
elapsed_time_ms = (time.time() - start_time) * 1000
print(f"OK {elapsed_time_ms} IMAP DELETE {username} {random_msg_num}")
elapsed_ms = (time.monotonic() - start_time) * 1000
log_ok(
ctx.stats,
IMAP_DELETE,
elapsed_ms,
f"{username} {random_msg_num.decode()}",
verbose=ctx.verbose,
)
else:
ctx.stats.record_skip(IMAP_DELETE)
except Exception as e:
print(f"ERR IMAP {e}")
log_err(ctx.stats, IMAP_DELETE, e, ctx.verbose)
def perform_random_action(credentials):
ACTION_FUNCS = (
smtp_send_message,
imap_append_message,
imap_list_fetch,
imap_delete_message,
)
def pick_recipient(credentials, sender):
if len(credentials) == 1:
return credentials[0][0]
while True:
recipient = random.choice(credentials)[0]
if recipient != sender:
return recipient
def perform_random_action(ctx, credentials):
username, password = random.choice(credentials)
recipient, _ = random.choice(credentials)
action = random.choice([smtp_send_message, imap_append_message, imap_list_fetch, imap_delete_message])
if action == smtp_send_message or action == imap_append_message:
action(username, password, recipient)
else:
action(username, password)
recipient = pick_recipient(credentials, username)
action = random.choice(ACTION_FUNCS)
action(ctx, username, password, recipient)
def thread_function(ctx, credentials):
count = 0
while not STOP_EVENT.is_set():
if ctx.runs is not None and count >= ctx.runs:
break
perform_random_action(ctx, credentials)
count += 1
def percentile(sorted_values, pct):
if not sorted_values:
return 0.0
if len(sorted_values) == 1:
return sorted_values[0]
rank = (len(sorted_values) - 1) * (pct / 100.0)
low = math.floor(rank)
high = math.ceil(rank)
if low == high:
return sorted_values[int(rank)]
return sorted_values[low] * (high - rank) + sorted_values[high] * (rank - low)
def stddev(values, mean):
if len(values) < 2:
return 0.0
variance = sum((v - mean) ** 2 for v in values) / (len(values) - 1)
return math.sqrt(variance)
def summarize(action, latencies, errors, skips, total_bytes, wall_seconds):
count = len(latencies)
summary = {
"action": action,
"count": count,
"errors": errors,
"skips": skips,
"mb": total_bytes / (1024 * 1024),
}
if count == 0:
for key in ("min", "max", "avg", "median", "p95", "p99", "stddev", "ops"):
summary[key] = 0.0
return summary
ordered = sorted(latencies)
mean = sum(ordered) / count
summary.update(
{
"min": ordered[0],
"max": ordered[-1],
"avg": mean,
"median": percentile(ordered, 50),
"p95": percentile(ordered, 95),
"p99": percentile(ordered, 99),
"stddev": stddev(ordered, mean),
"ops": count / wall_seconds if wall_seconds > 0 else 0.0,
}
)
return summary
def print_report(stats, wall_seconds):
latencies, errors, skips, byte_counts = stats.snapshot()
rows = []
all_latencies = []
total_errors = 0
total_skips = 0
total_bytes = 0
for action in ACTIONS:
action_latencies = latencies.get(action, [])
all_latencies.extend(action_latencies)
total_errors += errors.get(action, 0)
total_skips += skips.get(action, 0)
total_bytes += byte_counts.get(action, 0)
rows.append(
summarize(
action,
action_latencies,
errors.get(action, 0),
skips.get(action, 0),
byte_counts.get(action, 0),
wall_seconds,
)
)
rows.append(
summarize(
"TOTAL",
all_latencies,
total_errors,
total_skips,
total_bytes,
wall_seconds,
)
)
headers = [
"Action",
"OK",
"Err",
"Skip",
"Min ms",
"Max ms",
"Avg ms",
"Med ms",
"P95 ms",
"P99 ms",
"Std ms",
"Ops/s",
"MB",
]
fmt = "{:<12} {:>7} {:>5} {:>5} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>8} {:>9}"
line = "-" * 122
print()
print(line)
print(f"Stress test report (wall time: {wall_seconds:.2f}s)")
print(line)
print(fmt.format(*headers))
print(line)
for r in rows:
if r["action"] == "TOTAL":
print(line)
print(
fmt.format(
r["action"],
r["count"],
r["errors"],
r["skips"],
f"{r['min']:.2f}",
f"{r['max']:.2f}",
f"{r['avg']:.2f}",
f"{r['median']:.2f}",
f"{r['p95']:.2f}",
f"{r['p99']:.2f}",
f"{r['stddev']:.2f}",
f"{r['ops']:.1f}",
f"{r['mb']:.1f}",
)
)
print(line)
class Context:
def __init__(self, args, stats):
self.smtp_server = args.smtp_server
self.smtp_port = args.smtp_port
self.imap_server = args.imap_server
self.imap_port = args.imap_port
self.runs = args.runs
self.max_content_size = args.max_content_size
self.verbose = not args.quiet
self.stats = stats
def parse_args():
parser = argparse.ArgumentParser(
description="Concurrent SMTP/IMAP stress test for Stalwart."
)
parser.add_argument("--smtp-server", default=DEFAULT_SMTP_SERVER)
parser.add_argument("--smtp-port", type=int, default=DEFAULT_SMTP_PORT)
parser.add_argument("--imap-server", default=DEFAULT_IMAP_SERVER)
parser.add_argument("--imap-port", type=int, default=DEFAULT_IMAP_PORT)
parser.add_argument("--threads", type=int, default=DEFAULT_THREADS)
parser.add_argument(
"--runs",
type=int,
default=DEFAULT_RUNS,
help="Actions per thread. Use 0 for an infinite loop (stop with Ctrl-C).",
)
parser.add_argument("--credentials", default="users.txt")
parser.add_argument(
"--max-content-size",
type=int,
default=1048576,
help="Maximum random message body size in bytes.",
)
parser.add_argument(
"--dict",
default=DEFAULT_DICTIONARY,
help="Word list used to generate message text, one word per line.",
)
parser.add_argument(
"--quiet",
action="store_true",
help="Suppress per-operation logging; print only the final report.",
)
return parser.parse_args()
def thread_function(credentials):
if runs:
for _ in range(runs):
perform_random_action(credentials)
else:
while True:
perform_random_action(credentials)
def main():
credentials = read_credentials("users.txt")
threads = []
global WORDS
args = parse_args()
if args.runs == 0:
args.runs = None
WORDS = load_words(args.dict)
credentials = read_credentials(args.credentials)
stats = Stats()
ctx = Context(args, stats)
for _ in range(num_threads):
thread = threading.Thread(target=thread_function, args=(credentials,))
threads.append(thread)
thread.start()
threads = [
threading.Thread(target=thread_function, args=(ctx, credentials), daemon=True)
for _ in range(args.threads)
]
start = time.monotonic()
for thread in threads:
thread.join()
thread.start()
if __name__ == '__main__':
try:
while any(t.is_alive() for t in threads):
for t in threads:
t.join(timeout=0.2)
except KeyboardInterrupt:
print("\nStopping...")
STOP_EVENT.set()
for t in threads:
t.join()
wall_seconds = time.monotonic() - start
print_report(stats, wall_seconds)
if __name__ == "__main__":
main()

View File

@@ -1,57 +1,244 @@
import requests
import argparse
import base64
import json
import random
import ssl
import string
import urllib3
import urllib.error
import urllib.request
# Configuration Variables
HOSTNAME = '127.0.0.1' # Replace with the actual hostname
DOMAIN = 'test.org' # Replace with your domain name
USERNAME = 'admin' # Basic auth username
PASSWORD = 'secret' # Basic auth password
NUM_USERS = 1000 # Number of test user accounts to create
CORE = "urn:ietf:params:jmap:core"
STALWART = "urn:stalwart:jmap"
USING = [CORE, STALWART]
# Suppress InsecureRequestWarning
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
DEFAULT_BASE_URL = "https://127.0.0.1"
DEFAULT_NUM_USERS = 1000
DEFAULT_OUTPUT = "users.txt"
DEFAULT_PASSWORD_LENGTH = 16
DEFAULT_PREFIX = "test"
# Generate SHA512 password hash
def generate_password():
return ''.join(random.choices(string.ascii_letters + string.digits, k=10))
CREATE_RETRIES = 5
# Create Domain
def create_domain():
url = f"https://{HOSTNAME}/api/domain/{DOMAIN}"
response = requests.post(url, auth=(USERNAME, PASSWORD), verify=False)
if response.status_code == 200:
print(f"Domain '{DOMAIN}' created successfully.")
else:
print(f"Failed to create domain '{DOMAIN}'. Status Code: {response.status_code}")
print(response.text)
# Create User Accounts
def create_user_accounts():
with open('users.txt', 'w') as file:
for i in range(1, NUM_USERS + 1):
username = f"test{i}@{DOMAIN}"
password = generate_password()
data = {
"type": "individual",
"name": username,
"secrets": [password],
"emails": [username],
"description": f"Tester {i}"
class AccountError(Exception):
pass
def generate_password(length):
return "".join(random.choices(string.ascii_letters + string.digits, k=length))
def primary_account(session):
accounts = session.get("primaryAccounts") or {}
if "urn:ietf:params:jmap:mail" in accounts:
return accounts["urn:ietf:params:jmap:mail"]
all_accounts = session.get("accounts") or {}
return next(iter(all_accounts), None)
def build_opener(verify):
context = ssl.create_default_context()
if not verify:
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
handler = urllib.request.HTTPSHandler(context=context)
return urllib.request.build_opener(handler)
class JmapClient:
def __init__(self, base_url, auth_header, verify):
self.base_url = base_url.rstrip("/")
self.auth_header = auth_header
self.opener = build_opener(verify)
self.api_url = None
self.account_id = None
self._discover()
def _http(self, url, method, body=None):
headers = {"Authorization": self.auth_header}
data = None
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
request = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with self.opener.open(request, timeout=60) as response:
return response.status, response.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
except urllib.error.URLError as e:
raise SystemExit(f"Request to {url} failed: {e.reason}")
def _discover(self):
if "/.well-known/jmap" in self.base_url:
candidates = [self.base_url]
else:
candidates = [self.base_url, f"{self.base_url}/.well-known/jmap"]
last = ""
for url in candidates:
status, text = self._http(url, "GET")
if status == 200:
try:
data = json.loads(text)
except ValueError:
last = f"{url}: invalid JSON"
continue
if data.get("apiUrl") and data.get("accounts"):
self.api_url = data["apiUrl"]
self.account_id = primary_account(data)
if not self.account_id:
raise SystemExit(f"JMAP session at {url} has no accounts.")
return
last = f"{url}: status {status}"
raise SystemExit(f"Could not discover JMAP session ({last}).")
def request(self, method_calls):
body = {"using": USING, "methodCalls": method_calls}
status, text = self._http(self.api_url, "POST", body)
if status != 200:
raise SystemExit(f"JMAP request failed: status {status}: {text}")
return json.loads(text)
def call(self, method, args):
args = dict(args)
args["accountId"] = self.account_id
parsed = self.request([[method, args, "c0"]])
responses = parsed.get("methodResponses") or []
if not responses:
raise SystemExit(f"{method}: empty methodResponses")
name, payload = responses[0][0], responses[0][1]
if name == "error":
raise SystemExit(f"{method} error: {payload}")
return payload
def domain_id(self, name):
calls = [
[
"x:Domain/query",
{"accountId": self.account_id, "filter": {"name": name}},
"q",
],
[
"x:Domain/get",
{
"accountId": self.account_id,
"#ids": {
"resultOf": "q",
"name": "x:Domain/query",
"path": "/ids",
},
"properties": ["id", "name"],
},
"g",
],
]
parsed = self.request(calls)
responses = parsed.get("methodResponses") or []
if len(responses) < 2:
raise SystemExit(f"Domain lookup failed: {parsed}")
get = responses[1][1]
for entry in get.get("list") or []:
if entry.get("name") == name:
return entry.get("id")
return None
def create_account(self, localpart, domain_id, password):
create = {
"a": {
"@type": "User",
"name": localpart,
"domainId": domain_id,
"credentials": {"0": {"@type": "Password", "secret": password}},
"encryptionAtRest": {"@type": "Disabled"},
"permissions": {"@type": "Inherit"},
"roles": {"@type": "User"},
"locale": "en_US",
}
url = f"https://{HOSTNAME}/api/principal"
response = requests.post(url, json=data, auth=(USERNAME, PASSWORD), verify=False)
if response.status_code == 200:
file.write(f"{username}:{password}\n")
print(f"User account '{username}' created successfully.")
else:
print(f"Failed to create user account '{username}'. Status Code: {response.status_code}")
print(response.text)
}
response = self.call("x:Account/set", {"create": create})
not_created = response.get("notCreated") or {}
if not_created:
raise AccountError(f"{not_created.get('a', not_created)}")
created = (response.get("created") or {}).get("a") or {}
return created.get("id")
def invalidate_caches(self):
self.call(
"x:Action/set",
{"create": {"c": {"@type": "InvalidateCaches"}}},
)
def build_auth_header(args):
if args.token:
return f"Bearer {args.token}"
raw = f"{args.user}:{args.password}".encode("utf-8")
return "Basic " + base64.b64encode(raw).decode("ascii")
def parse_args():
parser = argparse.ArgumentParser(
description="Provision test accounts for the stress test using the JMAP API."
)
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
parser.add_argument(
"--domain",
required=True,
help="Existing domain name; resolved to a domain id via JMAP.",
)
parser.add_argument("--token", help="OAuth bearer token for the JMAP API.")
parser.add_argument("--user", help="Basic auth username for the JMAP API.")
parser.add_argument("--password", help="Basic auth password for the JMAP API.")
parser.add_argument("--num-users", type=int, default=DEFAULT_NUM_USERS)
parser.add_argument("--prefix", default=DEFAULT_PREFIX)
parser.add_argument("--password-length", type=int, default=DEFAULT_PASSWORD_LENGTH)
parser.add_argument("--output", default=DEFAULT_OUTPUT)
parser.add_argument(
"--verify",
action="store_true",
help="Verify TLS certificates (disabled by default for self-signed servers).",
)
args = parser.parse_args()
if not args.token and not (args.user and args.password):
parser.error("provide either --token or both --user and --password")
return args
def main():
create_domain()
create_user_accounts()
args = parse_args()
client = JmapClient(args.base_url, build_auth_header(args), args.verify)
domain_id = client.domain_id(args.domain)
if not domain_id:
raise SystemExit(f"Domain '{args.domain}' not found via JMAP.")
created = 0
failed = 0
with open(args.output, "w") as file:
for i in range(1, args.num_users + 1):
localpart = f"{args.prefix}{i}"
email = f"{localpart}@{args.domain}"
password = None
last_error = None
for _ in range(CREATE_RETRIES):
password = generate_password(args.password_length)
try:
client.create_account(localpart, domain_id, password)
last_error = None
break
except AccountError as e:
last_error = e
if last_error is not None:
failed += 1
print(f"FAIL {email}: {last_error}")
continue
file.write(f"{email}:{password}\n")
file.flush()
created += 1
print(f"OK {email}")
client.invalidate_caches()
print(f"\nCreated {created} accounts ({failed} failed). Written to {args.output}.")
if __name__ == "__main__":
main()

View File

@@ -183,6 +183,25 @@ pub async fn test(test: &TestServer) {
.is_none()
);
// Email/changes must not leak ids of emails in folders John cannot read
let jane_inbox_email = email_ids.get("jane").unwrap().first().unwrap().clone();
let jane_trash_email = email_ids.get("jane").unwrap().last().unwrap().clone();
let changed_ids = john_client
.set_default_account_id(jane.id_string())
.email_changes("n", None)
.await
.unwrap()
.created()
.to_vec();
assert!(
changed_ids.contains(&jane_inbox_email),
"Email/changes should report the shared Inbox email"
);
assert!(
!changed_ids.contains(&jane_trash_email),
"Email/changes leaked the id of a non-shared Trash email"
);
// John should only be able to copy blobs he has access to
let blob_id = jane_client
.email_get(