Skip to content

Server API

Server configuration, message handlers, and startup.

start_server

start_server async

start_server(
    config: ServerConfig, log_level: str = "INFO"
) -> None

Start a Misfin server.

Source code in src/titlani/server/server.py
async def start_server(
    config: ServerConfig,
    log_level: str = "INFO",
) -> None:
    """Start a Misfin server."""
    configure_logging(log_level=log_level)
    config.validate_files()

    certfile, keyfile, identity_certfile, identity_keyfile = _ensure_certificates(config)

    # Create TLS context using PyOpenSSL-based permissive context that
    # accepts any client cert (including self-signed) without CA validation.
    # Client identity is verified at the application layer via TOFU.
    ssl_context = create_permissive_server_context(
        certfile=str(certfile),
        keyfile=str(keyfile),
        request_client_cert=True,
    )

    middleware = _build_middleware(config)

    # Create mailbox directory with restrictive permissions
    config.server.mailbox_dir.mkdir(parents=True, exist_ok=True)
    os.chmod(config.server.mailbox_dir, 0o700)

    # Compute identity certificate fingerprint for probe responses
    id_cert = load_pem_x509_certificate(identity_certfile.read_bytes())
    id_fingerprint = normalize_fingerprint(get_certificate_fingerprint(id_cert))

    # Set up encryption if enabled
    encryption_manager = _setup_encryption(config)

    # Load per-mailbox recipient fingerprints
    cert_dir = config.server.identity_cert_dir or config.server.mailbox_dir
    recipient_fps = _load_recipient_fingerprints(cert_dir, id_fingerprint)

    # Create subscription token store for mailing lists
    subscription_store: SubscriptionTokenStore | None = None
    if config.lists.enable:
        from .lists import SUBSCRIPTION_DB_FILE

        subscription_store = SubscriptionTokenStore(
            config.server.mailbox_dir / SUBSCRIPTION_DB_FILE
        )

    # Create base handler
    base_handler = FileMailboxHandler(
        mailbox_dir=config.server.mailbox_dir,
        hostname=config.server.hostname,
        recipient_fingerprint_fn=lambda m: recipient_fps.get(m, id_fingerprint),
        identity_cert_fingerprint=id_fingerprint,
        encryption_manager=encryption_manager,
        auto_reply_enabled=config.auto_reply.enable,
        auto_reply_interval=config.auto_reply.interval,
        identity_certfile=identity_certfile,
        identity_keyfile=identity_keyfile,
        port=config.server.port,
        lists_enabled=config.lists.enable,
        lists_archive=config.lists.archive,
        subscription_store=subscription_store,
    )

    handler, cache = _setup_verification(
        config, base_handler, identity_certfile, identity_keyfile
    )

    # Start Misfin server — TLS handled by TLSServerProtocol (no ssl= param)
    loop = asyncio.get_running_loop()
    misfin_server = await loop.create_server(
        lambda: TLSServerProtocol(
            lambda: MisfinServerProtocol(
                message_handler=handler.handle_message,
                middleware=middleware,
            ),
            ssl_context,
        ),
        host=config.server.host,
        port=config.server.port,
    )

    logger.info(
        "server_started",
        host=config.server.host,
        port=config.server.port,
        hostname=config.server.hostname,
        rate_limiting=config.rate_limit.enable,
        access_control=config.access_control.enable,
        encryption=config.encryption.enable,
        gmap=config.gmap.enable,
        mailbox_dir=str(config.server.mailbox_dir),
    )

    gmap_server = await _start_gmap_server(
        config, certfile, keyfile, cert_dir, recipient_fps
    )

    try:
        if gmap_server is not None:
            async with misfin_server, gmap_server:
                await asyncio.gather(
                    misfin_server.serve_forever(),
                    gmap_server.serve_forever(),
                )
        else:
            async with misfin_server:
                await misfin_server.serve_forever()
    except asyncio.CancelledError:
        pass
    finally:
        if cache is not None:
            cache.close()
        if subscription_store is not None:
            subscription_store.close()
        logger.info("server_stopped")

ServerConfig

ServerConfig

Bases: BaseModel

MessageHandler

MessageHandler

Bases: ABC

FileMailboxHandler

FileMailboxHandler

FileMailboxHandler(
    mailbox_dir: Path,
    hostname: str,
    recipient_fingerprint_fn: Callable[[str], str | None]
    | None = None,
    identity_cert_fingerprint: str = "",
    encryption_manager: EncryptionManager | None = None,
    auto_reply_enabled: bool = False,
    auto_reply_interval: int = 86400,
    identity_certfile: Path | None = None,
    identity_keyfile: Path | None = None,
    port: int = DEFAULT_PORT,
    lists_enabled: bool = False,
    lists_archive: bool = True,
    subscription_store: SubscriptionTokenStore
    | None = None,
)

Bases: MessageHandler

Stores messages as .gemmail files in mailbox_dir//.

Source code in src/titlani/server/handler.py
def __init__(
    self,
    mailbox_dir: Path,
    hostname: str,
    recipient_fingerprint_fn: Callable[[str], str | None] | None = None,
    identity_cert_fingerprint: str = "",
    encryption_manager: EncryptionManager | None = None,
    auto_reply_enabled: bool = False,
    auto_reply_interval: int = 86400,
    identity_certfile: Path | None = None,
    identity_keyfile: Path | None = None,
    port: int = DEFAULT_PORT,
    lists_enabled: bool = False,
    lists_archive: bool = True,
    subscription_store: SubscriptionTokenStore | None = None,
) -> None:
    self.mailbox_dir = mailbox_dir
    self.hostname = hostname
    self.recipient_fingerprint_fn = recipient_fingerprint_fn
    self.identity_cert_fingerprint = identity_cert_fingerprint
    self.encryption_manager = encryption_manager
    self.auto_reply_enabled = auto_reply_enabled
    self.auto_reply_interval = auto_reply_interval
    self.identity_certfile = identity_certfile
    self.identity_keyfile = identity_keyfile
    self.port = port
    self.lists_enabled = lists_enabled
    self.lists_archive = lists_archive
    self.subscription_store = subscription_store
    self._auto_reply_last_sent: dict[str, datetime] = {}