sm-logtool v1.0.0 released: terminal log explorer for SmarterMail
Problem reported by John C. Reid - 4/23/2026 at 1:15 PM
Submitted
Hi everyone,

I wanted to share a tool I’ve been building for working with SmarterMail logs: sm-logtool, now at v1.0.0.


sm-logtool is a Python-based log explorer for SmarterMail with both:
  • a TUI (terminal UI) for interactive browsing and searching
  • a CLI mode for quick scripted searches and automation
I developed it primarily for the SmarterMail Linux server option, but since it is written in Python, it should work on Windows servers as well. If anyone tries it on Windows, I’d definitely like to hear how it goes.

What it does

sm-logtool is designed to make SmarterMail logs easier to work with when you need to dig into real issues instead of doing one-off checks.

 It includes:
  • interactive wizard-style log browsing
  • support for multiple SmarterMail log types
  • literal, wildcard, regex, and fuzzy search
  • grouped results that keep related log traffic together
  • syntax-highlighted output
  • live progress and cancel support for longer searches
  • a CLI mode for scripting and repeatable checks
  • a built-in theme converter so you can create your own color themes for the TUI from common theme formats like:
    • .itermcolors: iTerm2 color scheme files
    • .colors: line-based terminal color theme files
    • .colortheme: line-based terminal color theme files
Why use it instead of the built-in SmarterMail log viewer/search?

 The built-in tools are useful, but I wanted something that was more practical for deeper troubleshooting and repeated investigation work.

A few advantages of sm-logtool:

  • It gives you a dedicated search workflow built specifically around SmarterMail logs.
  • It supports more flexible search modes than a basic text search, including regex and fuzzy matching.
  • It is better suited for iterative troubleshooting, where you search, refine, compare results, and dig deeper. It does this by letting you search within search results as many levels deep as you need, with a back button.
  • The CLI mode makes it easy to automate searches or incorporate them into your own admin workflow.
  • The TUI is built for working directly in the terminal, which is especially convenient on Linux servers over SSH.
  • The theme system makes the interface easier to tune for your own eyes and terminal setup.
Setup and configuration

 Setup is intentionally simple.

 Install with pipx install sm-logtool (recommended) or python -m pip install sm-logtool, point it at your SmarterMail logs, and you’re basically ready to go. Configuration is lightweight, and the project includes a sample config template. It stages log copies into a working directory so you can search safely without touching the original logs.

 You can also optionally improve search performance greatly, especially fuzzy search, by installing with pipx install "sm-logtool[speedups]" or python -m pip install "sm-logtool[speedups]"

 When new releases come out, upgrading is as easy as pipx upgrade sm-logtool or python -m pip install --upgrade sm-logtool.

In general, pipx is preferred as it has better dependency handling. 

 If you want a terminal-based tool that is easy to install, easy to configure, and more capable than basic built-in searching, this may be useful.

Feedback welcome

 If you manage SmarterMail and this looks interesting, please give it a try and let me know what works, what doesn’t, and what you’d want added or improved.

 I’d especially appreciate feedback on:
  • Windows server use
  • additional log workflows people want most
  • usability improvements
  • theme ideas
  • scripting/automation use cases
 Project page again:

John C. Reid  / Technology Director
John@prime42.net  / (530) 691-0042
1300 West Street, Suite 206, Redding, CA 96001

Zach Sylvester Replied
Employee Post
Hey John, 

That is a really cool project. One thing that might be nice is to build in an MCP server so AI claude/codex etc could use MCP tools to look for issues in SmarterMail logs. Heres an example of what an MCP interface looks like. 

https://github.com/zjs81/barnacle-search/blob/master/src/code_indexer/server.py#L641

Regards, 

Zach Sylvester

Software Developer
SmarterTools Inc.
John C. Reid Replied
@Zach Sylvester,

I can certainly look into that. Would you mind creating a feature request issue on the project page?
John C. Reid  / Technology Director
John@prime42.net  / (530) 691-0042
1300 West Street, Suite 206, Redding, CA 96001

John C. Reid Replied
One thing I really think that it needs, but I am not quite sure how to implement it: I would like to be able to select an SMTP log conversation, and have it find the associated delivery log entries for you. 

I would love suggestions on how that might look in the TUI interface. 
John C. Reid  / Technology Director
John@prime42.net  / (530) 691-0042
1300 West Street, Suite 206, Redding, CA 96001

Sabatino Replied
There's a project of mine from a few months ago.
Then it stopped because it didn't have much feedback.
I use it on my installation and it gives me various responses.

If you're interested, I can update the download link.
Sabatino Traini
      Chief Information Officer
Genial s.r.l. 
Martinsicuro - Italy

John C. Reid Replied
Sabatino,

While I don't have interest is chasing IPs that tightly, the CLI function of sm-logtool might make a good helper, as you can script specific searches and get that returned to you. 

I noted you had a thread where the server tends to stall in the middle of the day and you think it might be high I/O wait. Have you considered that it could be Network I/O wait and not disk. If you are micromanaging the IP whitelisting/blacklisting this much, you are adding significant extra load as the server has to compare the connecting IP to both lists with every connection, and the bigger those lists get, the longer that takes. During peek times, it could stall things or even lock up the SmarterMail service. You could be creating your own issues. - Just a thought.

One way to unload that might be to put a firewall in front of the mail server. In my DC I have a Proxmox Mail Gateway which handles all inbound SMTP traffic, so the server only deals with SMTP Submit and the forwarding of the scored mail from the gateway, plus client traffic. The mail server itself is on a 3 node PVE fully hyper converged cluster. In front of everything in my DC network is a hardware firewall (OPNSense) that has CrowdSec running on it, and all of my servers (both physical and virtual) only have private IPs, with all the public IPs bound on the firewall and 1:1 NAT and port forwarded to the servers. This means all services pass through CrowdSec before ever hitting the daemon, and that workload happens on separate, dedicated hardware. Between these two things, my busy server (currently 338 domains, 1812 accounts, and 1141 aliases) never really bogs down anymore. (I have my indexing set to 2 - it does matter if you are getting bogged down)
John C. Reid  / Technology Director
John@prime42.net  / (530) 691-0042
1300 West Street, Suite 206, Redding, CA 96001

Sabatino Replied
No, I wasn't the one complaining about mid-day crashes.
I haven't experienced any slowdowns on the server.
Sabatino Traini
      Chief Information Officer
Genial s.r.l. 
Martinsicuro - Italy

John C. Reid Replied
My mistake. 
John C. Reid  / Technology Director
John@prime42.net  / (530) 691-0042
1300 West Street, Suite 206, Redding, CA 96001

Sabatino Replied
My program was a bit too ambitious.
My goal was to identify recurring patterns and block them.
Failed login attempts versus successful ones.
Let me explain: if 1 IP has 100 failed logins (spread out over time, otherwise the ids rule comes into play) on 100 different accounts and 1 successful login on one account, I think the system should go into alarm.
It's likely that the password for that account is compromised.
And that's one of many things.
The project isn't dead; I only use it internally, so I'm less careful about graphics, translations, and so on.
Other things I wanted to know are the forward volume for each account, for example.
Sabatino Traini
      Chief Information Officer
Genial s.r.l. 
Martinsicuro - Italy

John C. Reid Replied
I use this Python script to check out an IP. I am in a shell all day everyday, so a quick command is less friction for me than a web interface. I store my secrets like API keys in AWS SSM Parameter store as secrets, but I still redacted them just in case. 

#!/usr/bin/env python3

# Script Name: whosip
# Description: Show geo and risk information for an IP address or hostname
# Author: John C. Reid
# Created: 1/1/2019
# Modified: 4/1/2026
# Usage: whosip {ip-or-hostname}

import argparse
import os
import sys
from typing import Dict, List, Optional, Sequence, Tuple

import requests

try:
    import boto3
    from botocore.exceptions import BotoCoreError, ClientError
except ImportError:  # pragma: no cover - dependency presence is environment-specific.
    boto3 = None
    BotoCoreError = ClientError = Exception


KEYCDN_URL = "https://tools.keycdn.com/geo.json"
KEYCDN_USER_AGENT = "keycdn-tools:{{REDACTED}}"
SCAMALYTICS_URL = "{{REDACTED}}"
REQUEST_TIMEOUT = 30
DEFAULT_SSM_REGION = "us-west-2"
DEFAULT_SCAMALYTICS_PARAMETER = (
    "{{REDACTED}}"
)
API_KEY_PARAMETER_ENV = "SCAMALYTICS_API_KEY_PARAMETER"
API_KEY_ENV = "SCAMALYTICS_API_KEY"

RESET = "\033[0m"
BLUE = "\033[34m"
GREEN = "\033[32m"
RED = "\033[31m"
DIM = "\033[90m"


class SSMParameterError(RuntimeError):
    """Raised when a Parameter Store value cannot be retrieved."""


def fail(message: str, code: int = 1) -> None:
    print(f"ERROR: {message}", file=sys.stderr)
    raise SystemExit(code)


def warn(message: str) -> None:
    print(f"WARNING: {message}", file=sys.stderr)


def ssm_client_for_parameter(parameter_name: str):
    region = DEFAULT_SSM_REGION

    if parameter_name.startswith("arn:aws:ssm:"):
        arn_parts = parameter_name.split(":")
        if len(arn_parts) > 3 and arn_parts[3]:
            region = arn_parts[3]
    else:
        region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or DEFAULT_SSM_REGION

    return boto3.client("ssm", region_name=region)


def get_ssm_parameter(parameter_name: str) -> str:
    if boto3 is None:
        raise SSMParameterError(
            "boto3 is required to retrieve the Scamalytics API key from Parameter Store."
        )

    try:
        client = ssm_client_for_parameter(parameter_name)
        response = client.get_parameter(Name=parameter_name, WithDecryption=True)
    except (BotoCoreError, ClientError) as exc:
        raise SSMParameterError(
            f"Unable to retrieve Scamalytics API key from Parameter Store: {exc}"
        ) from exc

    value = response.get("Parameter", {}).get("Value", "").strip()
    if not value:
        raise SSMParameterError("Retrieved Scamalytics API key from Parameter Store was empty.")

    return value


def resolve_api_key(parameter_name: Optional[str]) -> str:
    if parameter_name:
        return get_ssm_parameter(parameter_name)

    parameter_from_env = os.environ.get(API_KEY_PARAMETER_ENV, "").strip()
    if parameter_from_env:
        return get_ssm_parameter(parameter_from_env)

    api_key = os.environ.get(API_KEY_ENV, "").strip()
    if api_key:
        warn("Using Scamalytics API key from environment. Prefer Parameter Store.")
        return api_key

    return get_ssm_parameter(DEFAULT_SCAMALYTICS_PARAMETER)


def request_json(url: str, *, params: Dict[str, str], headers: Optional[Dict[str, str]] = None) -> Dict:
    try:
        response = requests.get(url, params=params, headers=headers, timeout=REQUEST_TIMEOUT)
    except requests.RequestException as exc:
        fail(f"Unable to complete request to {url}: {exc}")

    if response.status_code != 200:
        fail(f"Received unexpected status code {response.status_code} from {url}.")

    try:
        return response.json()
    except ValueError as exc:
        fail(f"Response from {url} was not valid JSON: {exc}")


def as_display_value(value) -> str:
    if value is None or value == "":
        return "N/A"
    if isinstance(value, bool):
        return "true" if value else "false"
    return str(value)


def keycdn_pairs(keycdn_data: Dict) -> List[Tuple[str, str]]:
    geo = keycdn_data.get("data", {}).get("geo", {})
    return [(key, as_display_value(value)) for key, value in geo.items()]


def scamalytics_pairs(scamalytics_data: Dict) -> List[Tuple[str, str]]:
    scam = scamalytics_data.get("scamalytics", {})
    if scam.get("status", "") != "ok":
        return [("Scamalytics Status", as_display_value(scam.get("status", "error")))]

    proxy = scam.get("scamalytics_proxy", {})
    credits = scam.get("credits", {})
    external = scamalytics_data.get("external_datasources", {})
    firehol = external.get("firehol", {})
    ipsum = external.get("ipsum", {})
    spamhaus = external.get("spamhaus_drop", {})
    x4bnet = external.get("x4bnet", {})

    return [
        ("IP", as_display_value(scam.get("ip"))),
        ("Risk Score", as_display_value(scam.get("scamalytics_score"))),
        ("Risk Level", as_display_value(scam.get("scamalytics_risk"))),
        ("ISP Risk Score", as_display_value(scam.get("scamalytics_isp_score"))),
        ("ISP Risk Level", as_display_value(scam.get("scamalytics_isp_risk"))),
        ("Is Datacenter", as_display_value(proxy.get("is_datacenter"))),
        ("Is VPN", as_display_value(proxy.get("is_vpn"))),
        ("Blacklisted (External)", as_display_value(scamalytics_data.get("is_blacklisted_external"))),
        ("Firehol, Blacklisted 30", as_display_value(firehol.get("ip_blacklisted_30"))),
        ("Firehol, Blacklisted 1 Day", as_display_value(firehol.get("ip_blacklisted_1day"))),
        ("Firehol, Is Proxy", as_display_value(firehol.get("is_proxy"))),
        ("Ipsum, Blacklisted", as_display_value(ipsum.get("ip_blacklisted"))),
        ("Ipsum, Blacklists", as_display_value(ipsum.get("num_blacklists"))),
        ("Spamhaus DROP, Blacklisted", as_display_value(spamhaus.get("ip_blacklisted"))),
        ("X4B, Is VPN", as_display_value(x4bnet.get("is_vpn"))),
        ("X4B, Is Datacenter", as_display_value(x4bnet.get("is_datacenter"))),
        ("X4B, Is Tor", as_display_value(x4bnet.get("is_tor"))),
        ("X4B, Blacklisted Spambot", as_display_value(x4bnet.get("is_blacklisted_spambot"))),
        ("X4B, Bot: Opera Mini", as_display_value(x4bnet.get("is_bot_operamini"))),
        ("X4B, Bot: Semrush", as_display_value(x4bnet.get("is_bot_semrush"))),
        ("Credits Used", as_display_value(credits.get("used"))),
        ("Credits Remaining", as_display_value(credits.get("remaining"))),
    ]


def value_color(value: str) -> str:
    if value == "true":
        return RED
    if value in {"false", "N/A"}:
        return DIM
    return GREEN


def print_label_value_pairs(pairs: Sequence[Tuple[str, str]]) -> None:
    if not pairs:
        print(f"{DIM}N/A{RESET}")
        return

    max_label = max(len(label) for label, _ in pairs)
    for label, value in pairs:
        print(f"{BLUE}{label.rjust(max_label)}{RESET}: {value_color(value)}{value}{RESET}")


def print_geo_and_risk_section(target: str, api_key_parameter: Optional[str]) -> None:
    print()

    keycdn_data = request_json(
        KEYCDN_URL,
        params={"host": target},
        headers={"User-Agent": KEYCDN_USER_AGENT},
    )
    pairs = keycdn_pairs(keycdn_data)

    resolved_ip = as_display_value(keycdn_data.get("data", {}).get("geo", {}).get("ip"))
    scam_target_ip = resolved_ip if resolved_ip != "N/A" else target

    try:
        api_key = resolve_api_key(api_key_parameter)
    except SSMParameterError as exc:
        warn(str(exc))
        pairs.append(("Scamalytics Status", "N/A"))
        print_label_value_pairs(pairs)
        print()
        return

    scamalytics_data = request_json(
        SCAMALYTICS_URL,
        params={"key": api_key, "ip": scam_target_ip},
    )
    pairs.extend(scamalytics_pairs(scamalytics_data))
    print_label_value_pairs(pairs)
    print()


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Show geo and risk information for an IP address or hostname."
    )
    parser.add_argument("target", help="IP address or hostname to inspect")
    parser.add_argument(
        "--api-key-parameter",
        help=(
            "AWS Systems Manager Parameter Store parameter name containing the "
            "Scamalytics API key. Defaults to SCAMALYTICS_API_KEY_PARAMETER "
            f"or {DEFAULT_SCAMALYTICS_PARAMETER}."
        ),
    )
    args = parser.parse_args()

    target = args.target.strip()
    if not target:
        fail("An IP address or hostname is required.")

    print_geo_and_risk_section(target, args.api_key_parameter.strip() if args.api_key_parameter else None)


if __name__ == "__main__":
    main()



John C. Reid  / Technology Director
John@prime42.net  / (530) 691-0042
1300 West Street, Suite 206, Redding, CA 96001

Reply to Thread

Enter the verification text