Read FTN message bases.
Keep their history intact.
Read MSG, JAM, Squish and Hudson through a shared Python model. Get the message text, routing metadata and source location without writing a binary parser.
Your first message
Write a message into a temporary MSG area, then read it back. No old archive needed. This example cleans up after itself.
from pathlib import Path
from tempfile import TemporaryDirectory
from golded_ftn import OutgoingMessage
from golded_ftn_msg import MsgReader, MsgWriter
with TemporaryDirectory() as root:
area = Path(root) / "area"
writer = MsgWriter()
writer.create(area)
message = OutgoingMessage(
from_name="Alice", to_name="Bob",
subject="Hello, FidoNet", body_text="Still here.",
)
with writer.open(area) as session:
result = session.append(message)
assert result.identity.msgno == 1
for received in MsgReader().read(area):
print(received.subject)
print(received.from_name)
Hello, FidoNet Alice
Append returns a message identity and revision token. The reader returns ParsedMessage objects. All four formats have session writers in the 1.2.0 release. GoldED interoperability tests are deferred, so concurrent mode remains disabled.
Install the packages first using the next section. Save a runnable example as a .py file and run it in that environment.
Install from PyPI
Use Python 3.12 or newer and uv. All five packages are available on PyPI at 1.2.0.
mkdir ftn-examples
cd ftn-examples
uv venv --python 3.14
uv pip install --python .venv golded-ftn==1.2.0 golded-ftn-msg==1.2.0 golded-ftn-jam==1.2.0 golded-ftn-squish==1.2.0 golded-ftn-hudson==1.2.0
uv run --no-project --python .venv first_message.py
Put first_message.py inside ftn-examples/ before the last command. On Windows, run the same commands in PowerShell with uv installed.
For development inside a reader checkout, uv sync --locked uses the sibling golded-ftn-python directory. Built wheels keep the normal golded-ftn>=1.2.0,<2 dependency. The core has no runtime dependencies.
Choose a reader
Repository names end in -python. Distribution names don’t. Imports use underscores. Paths describe an area or base, not a single message.
| Format | Repository / distribution / import | Public entry point | Input |
|---|---|---|---|
| Core | golded-ftn-python ↗golded-ftngolded_ftn | Models & helpers | No message-base files |
| MSG / Opus | golded-ftn-msg-python ↗golded-ftn-msggolded_ftn_msg | MsgReader / MsgWriter | Directory of numbered .MSG files |
| JAM | golded-ftn-jam-python ↗golded-ftn-jamgolded_ftn_jam | JamReader / JamWriter | Basename + .JHR, .JDT, .JDX |
| Squish | golded-ftn-squish-python ↗golded-ftn-squishgolded_ftn_squish | SquishReader / SquishWriter | Basename + .SQD, .SQI |
| Hudson | golded-ftn-hudson-python ↗golded-ftn-hudsongolded_ftn_hudson | HudsonReader / HudsonWriter | Directory with MSGIDX.BBS, MSGHDR.BBS, MSGTXT.BBS |
from pathlib import Path
from golded_ftn import MessageBaseReader
from golded_ftn_msg import MsgReader
from golded_ftn_jam import JamReader
from golded_ftn_squish import SquishReader
from golded_ftn_hudson import HudsonReader
sources: list[tuple[MessageBaseReader, Path]] = [
(MsgReader(), Path("archives/msg/general")),
(MsgReader(header_format="opus"), Path("archives/opus/general")),
(JamReader(), Path("archives/jam/general")),
(SquishReader(), Path("archives/squish/general")),
(HudsonReader(), Path("archives/hudson")),
]
for reader, source in sources:
for message in reader.read(source):
print(message.msgno, message.from_name, message.subject)
Replace these paths with your own stable copies. For JAM and Squish, pass general, not general.JHR or general.SQD. Hudson reads all boards in one base:
from golded_ftn_hudson import HudsonReader
for message in HudsonReader().read("archives/hudson"):
if message.area_code == "BOARD7":
print(message.subject)
BOARD7 is a synthetic board identifier. Hudson does not store your configured area names. FTSC and Opus MSG layouts are selected explicitly; the reader does not guess which header you have.
One message model
A MessageBaseReader exposes read(path, options=None). Call it through the same protocol regardless of the binary format. It returns an iterable; materialize it with list(...) when you need a collected result.
from os import PathLike
from golded_ftn import MessageBaseReader
from golded_ftn_jam import JamReader
def show_subjects(reader: MessageBaseReader, source: str | PathLike[str]) -> None:
for message in reader.read(source):
print(message.msgno, message.subject)
if message.provenance is not None:
print(message.provenance.source_type,
message.provenance.source_id,
message.provenance.source_offset)
show_subjects(JamReader(), "archives/jam/general")
Messages are frozen values. Sender and recipient addresses are decoded strings or None; FtnAddress is the structured address type used for outgoing messages. Collections in the core model are tuples.
msgno- The source message number. It is not globally unique across areas.
external_id- MSGID when available; otherwise a core-generated
hash:sha256:ID over decoded, normalized fields. Keep provenance separately: an ID is not a file location. provenance- Source type, actual file path, source identity and byte offset when known. Preserve original bytes separately if you need a byte-faithful archive.
control_lines- Parsed kludges, MSGID, REPLY, charset, SEEN-BY and PATH. Repeated metadata is retained in the model’s ordered sequences.
- Reply links
reply_to_msgno,reply1st_msgnoandreply_next_msgnoare format-dependent. Missing or zero links becomeNone.
from golded_ftn import parse_message
text = (
"\x01MSGID: 2:236/77 demo\n"
"\x01REPLY: 2:236/77 earlier\n"
"Hello.\nSEEN-BY: 236/77 100\n"
"PATH: 236/77\n"
)
controls = parse_message(text)
print(controls.msgid)
print(controls.seen_by)
print(controls.path)
2:236/77 demo
('236/77 100',)
('236/77',)
parse_message() parses metadata; it does not remove controls from the input text. MSG and Hudson bodies can still contain control lines. JAM header controls and Squish control buffers are represented as metadata without being appended to the body.
Text, encodings and dates
Readers decode strictly. They use supported charset declarations and fall back to CP850 when no usable declaration is present. Configure another fallback with ReaderOptions(fallback_charset="UTF-8"). A fallback is a policy choice, not permission to decode with replacement characters.
from golded_ftn import detect_charset, to_utf8, parse_body, repair_mojibake
raw = b"\x01CHRS: IBMPC 2\r\nBruger m\x9bde\x00"
charset = detect_charset(raw)
text = parse_body(to_utf8(raw, charset))
print(charset)
print(text.splitlines()[-1])
# A separate, opt-in operation on decoded text.
repair = repair_mojibake("Bruger m°de")
print(repair.text)
CP850 Bruger møde Bruger møde
parse_body() normalizes line endings and removes trailing NULs. to_utf8() returns a Python string, not encoded bytes. Mojibake repair is never automatic; retain the decoded original if you apply it. Its confidence is a heuristic, not a probability.
| Format | posted_at |
|---|---|
| FTSC MSG / Opus | Naive datetime; Opus uses the packed written timestamp with a textual fallback. |
| JAM | Naive datetime from epoch + DateWritten seconds; zero becomes None. No machine-local timezone conversion. |
| Squish | Valid packed written date is UTC-aware. A permitted textual fallback is naive. Invalid dates become None. |
| Hudson | Naive MM-DD-YY HH:MM date; 00–79 maps to 2000–2079, 80–99 to 1980–1999. |
Timezone controls remain metadata. Do not assume every posted_at has a timezone, or sort mixed aware and naive datetimes without an explicit policy.
Strict reading and archive mode
Strict reading is the default. Filesystem failures stay filesystem exceptions. Damaged records and decoding failures raise ParserException with source context and a chained cause.
from golded_ftn import ParserException
from golded_ftn_jam import JamReader
try:
messages = list(JamReader().read("archives/jam/general"))
except OSError as error:
print("Cannot access the source:", error)
except ParserException as error:
print("Cannot parse the source:", error)
else:
print("Messages:", len(messages))
Do not treat an exception as end-of-file. MSG can yield earlier messages before a later file fails. Strict JAM, Squish and Hudson validate their active records before exposing a result. Collecting with list() makes that boundary explicit for your caller.
Archive mode is a reported compromise
Use it when you need recoverable content from an imperfect archive. A callback is required. It receives metadata-only issues; it does not receive message contents.
from golded_ftn import ReaderIssue, ReaderOptions
from golded_ftn_jam import JamReader
issues: list[ReaderIssue] = []
options = ReaderOptions(archive_mode=True, on_issue=issues.append)
messages = list(JamReader().read("archives/jam/general", options))
for issue in issues:
print(issue.action, issue.code, issue.source_path,
issue.source_id, issue.source_offset)
complete_traversal = not any(issue.action == "stopped" for issue in issues)
print("Messages:", len(messages))
print("Traversal completed:", complete_traversal)
# A completed traversal can still have skipped messages.
- recovered
- A supported deviation was handled and reported.
- skipped
- A record was not returned. Traversal can continue where the reader has a safe boundary.
- stopped
- Traversal could not safely continue. A validated prefix may be returned.
Issue counts are not message counts: one record can produce several issues. No stopped issue means traversal completed, not that every source message was recovered. Callback exceptions and filesystem errors still propagate. Other readers use the same ReaderOptions contract; each has its own bounded recovery rules.
Edit message bases
Create a base, open a session, then append, read, update and delete. Edits require the revision from a consistent session read. Stop GoldED before opening a writer session. The example uses FTSC MSG; JamWriter and SquishWriter use the same API. HudsonWriter.open also requires an explicit board.
from pathlib import Path
from tempfile import TemporaryDirectory
from golded_ftn import FtnAddress, MessagePatch, OutgoingMessage, WriterOptions
from golded_ftn_msg import MsgWriter
with TemporaryDirectory() as root:
area = Path(root) / "area"
writer = MsgWriter()
writer.create(area)
outgoing = OutgoingMessage(
from_name="Alice", to_name="Bob", subject="Netmail",
body_text="A message from Python.",
from_address=FtnAddress.from_string("2:236/77.1"),
to_address=FtnAddress.from_string("2:236/100"),
)
with writer.open(area, WriterOptions(target_charset="CP850")) as session:
added = session.append(outgoing)
received = session.read(added.identity.msgno)
print(added.identity.msgno)
print(received.message.from_address)
print(received.message.to_address)
changed = session.update(
received.identity, MessagePatch(subject="Edited"), received.revision,
)
session.delete(changed.identity, changed.revision)
1 2:236/77.1 2:236/100
Outgoing addresses use FtnAddress. The writer handles the corresponding INTL/FMPT/TOPT controls and charset metadata. Only use characters that the target charset can encode. Each operation is a commit unit. Earlier completed operations survive a later failure. See the writer contract and verification boundary and the complete golded-ftn API reference.
Limits and further reading
Read a stable copy. These readers do not lock a live base or take an atomic snapshot across its files. Stop the writer or use a consistent copy.
- No area discovery, database adapters or automatic Nornir ingestion.
- JAM and Squish use their indexes as authority; obsolete, unindexed headers are not returned.
- JAM rejects compressed, encrypted or escaped active messages. Squish supports classic layouts, not compressed or in-progress frames.
- Hudson supports classic .BBS files, not GoldBase .DAT layouts.
- Unknown metadata stays unknown. The libraries do not invent configured area names or timezones.
- Multi-file readers load their source files into memory. Allow for the size of the whole base.
- golded-ftn · README · Changelog · Private security report
- golded-ftn-msg · README · Changelog · Private security report
- golded-ftn-jam · README · Changelog · Private security report
- golded-ftn-squish · README · Changelog · Private security report
- golded-ftn-hudson · README · Changelog · Private security report
Format reference: JAM-001 revision 1. Each package README links to its format references and GoldED structures.
JAM(mbp) - Copyright 1993 Joaquim Homrighausen, Andrew Milner, Mats Birch, Mats Wallin. ALL RIGHTS RESERVED.
The library code and this guide are MIT licensed. Format specifications and bundled fonts retain their own terms.
