English · Deutsch
A native, cross-platform QWK(E) offline reader with automatic packet exchange against NullModem BBS. ANSI art is rendered through the same grid the BBS itself uses.
The reader builds on the kit,
the foundation it shares with NullModem BBS: ansi (grid, CP437, SGR,
layout, templates), qwk (the QWK/QWKE format layer) and zmodem.
Server and reader have to agree on file format and screen rendering; two
copies of the same code drift apart from the first bug fix on, and with
QWK you only notice when someone's mail goes missing.
| Package | Role |
|---|---|
internal/app |
The whole interface. Knows neither terminal nor window: keys in, ansi.Grid out |
internal/tui |
Terminal front end -- paints the grid into tcell cells |
internal/gui |
Window front end -- paints the same grid with a CP437 bitmap font |
internal/exchange |
One mail exchange: send, then fetch -- plus the lock that keeps two from running at once |
internal/sched |
Unattended polling: interval, jitter, backoff |
internal/xfer, store, compose, ui, config |
Transport, queue and read pointers, composing, grid preparation, configuration |
ansi.Grid is the only representation of screen content; everything
else is a blitter over it:
| Blitter | Target |
|---|---|
ansi.Grid.Encode (from the BBS) |
a terminal that speaks CP437 natively (SyncTERM, PuTTY over Telnet) |
ansi.ToHTML (from the BBS) |
web preview |
ui.GridToTerminal |
a modern UTF-8 terminal, line by line |
tui.present |
tcell full screen, cell by cell |
gui.paint |
a window, CP437 bitmap font via Cell.Char |
gui.Rasterize |
PNG -- the same picture as the GUI, without a window |
Not only the art takes this path but the whole interface: lists, headers, the status bar and the input form are drawn into a grid. A front end is thus a blitter and an input source, not a second copy of the interface -- which is why terminal and window can't drift apart.
The UTF-8 blitter is needed because raw CP437 bytes arrive as mojibake in a UTF-8 terminal. Going through the grid also resolves cursor-addressed art that isn't drawn in character order.
Done and tested
- The QWK/QWKE format layer in both directions -- the BBS only needed
to write, the reader needs the reading side too:
ReadControlDAT,OpenPacket,ParseQWKEKludges,ReadToReaderEXT,BuildReplyPacket - Transport against the BBS API: login, download (204 = no new mail), upload, area selection
- Rendering: ANSI art, CP437 prose, ASCII-art detection, header fields
- TUI (tcell): conference list → message list → message, welcome screen, scrolling, help overlay, resize. Tested against tcell's simulation screen, not just looked at by hand.
- Writing replies:
rreplies (recipient, subject and quote filled in),ewrites a new message. You write the text in the built-in editor (see Writing); set$VISUAL/$EDITORto get your own instead -- key bindings, undo, spell checking. Drafts go into a queue that survives a restart;nmr fetchbuilds a.REPfrom it and sends it. - Read pointers: unread messages are marked
•, the conference list shows3/12 msg, a conference opens at the first unread message,mmarks everything read. Kept across sessions and packets. - GUI (Ebitengine): its own window with an embedded CP437 8×16 font, integer scaling, DOS palette. The same interface as in the terminal, pixel-exact -- block graphics tile seamlessly.
- Windows touches: a program icon (drawn by
tools/mkiconfrom the CP437 font) and version details innmr.exe, the same icon for the window; held arrow, page and delete keys repeat in the window - Setup and exchange inside the reader: a setup dialog on first
start,
fexchanges mail in the background, the password in the system's keychain -- see First start without a command line - Scheduler:
nmr daemonexchanges on a schedule -- an interval per system, jitter against simultaneous access, exponential backoff on failures (capped at one hour), a file lock against double runs. - The
nmrcommand line:open,gui,init,fetch,daemon,outbox,areas,list,read,screen - Builds for linux, darwin and windows, amd64 and arm64 each -- all without cgo, so from a single machine (see Releases)
Open
- The kit's
zmodemis ready but unused, in case a serial link is ever needed.
Ready builds are under
Releases: one
archive per platform with nmr (or nmr.exe), this README, the license
and the font license, plus SHA256SUMS to check them
(sha256sum -c SHA256SUMS). nmr version shows which version runs.
-
Linux: needs glibc (not Alpine/musl) -- Ebitengine loads X11 at run time, without cgo.
nmr guialso needs X11 or XWayland and OpenGL; the terminal mode needs neither. -
macOS: the binaries aren't signed. After downloading, run
xattr -d com.apple.quarantine nmronce, or Gatekeeper blocks it. -
Windows: double-clicking
nmr.exeopens the window. On first start it asks for the BBS address, user name and password, checks the login and fetches the first mail right away. From a command prompt, all the commands below work as well.On first start Windows SmartScreen warns ("Windows protected your PC") because
nmr.exeis unsigned and new. Either choose "More info" → "Run anyway", or before unpacking right-click the ZIP → Properties → tick "Unblock". Only needed once.
nmr gui (on Windows also the double-click) and nmr open need no
packet as argument: with nothing set up, the setup dialog appears; set
up but nothing downloaded yet, the reader fetches mail at once;
otherwise it shows all downloaded packets together as one view. What
was unread before an exchange stays that way until it's read -- the BBS
delivers a message only once.
Enter in the form opens the built-in editor in the same window: arrows,
Home/End, Page Up/Down; paragraphs wrap at the window's edge and at 79
columns when saved. In a reply the quote is already there (without the
original's tearline and origin), the cursor below it. Ctrl+Y deletes
the whole line -- held down, one after the other, so a quote is
trimmed quickly. Ctrl+S puts the message in the queue, Esc asks
before discarding. The reader adds the tagline and tearline itself.
An echomail's SEEN-BY and PATH lines (NullModem BBS includes them from
0.26.2) are hidden; S in the message shows them. In a reply, Ctrl+R
inserts them as a quote (BT> SEEN-BY: …) -- quoted, because a bare
SEEN-BY: line in the text would be taken for real routing by any
tosser.
Paste works everywhere with Ctrl+V (Cmd+V on a Mac), multi-line in
the editor, the first line in fields. In the window the reader reads
the clipboard itself -- directly on Windows, via pbpaste on a Mac, and
via wl-paste, xclip or xsel on Linux, whichever is installed. In a
terminal, the terminal's own paste works too.
With VISUAL or EDITOR set, the reader uses that editor instead of
the built-in one, as before.
N writes a new netmail from anywhere; the netmail conference
"Personal" is in the list even without mail (NullModem BBS marks it in
the packet since 0.26.0). The form asks for a name and an address: an
empty address means someone on this BBS, otherwise an FTN address like
2:301/1.5 -- the reader then sends Name@2:301/1.5. Netmail from
another system shows its sender in the same form, so a reply finds its
way back.
If the BBS refuses a message (unknown recipient, no write access, …),
it stays in the queue with the reason (o, marked !) and isn't sent
again: e opens it for correction, after which it goes out with the
next exchange; d discards it.
New messages and replies get a tagline if you like, as ... text
right above the tearline (--- NullModem Reader/nmr <version>). The
"Tagline" row in the form chooses with ←/→: none, random (space draws
another) or a specific one. The choice is the default for the next
message.
About 20 taglines are built in. Your own go in taglines.txt next to
the configuration file (%APPDATA%\nmr\ on Windows), one per line,
# starts a comment; more than 75 characters don't fit on a line and
are skipped.
Cleanup is set in the setup dialog under "Keep days" (keep_days in
the configuration, per BBS): a packet in which everything is read is
deleted after that many days, and so are sent reply packets; the newest
packet always stays. 0 keeps everything -- as does a configuration
without the entry. A new setup suggests 30 days. Cleanup runs at start
and after every exchange, also with nmr fetch and nmr daemon. To
change something in the dialog, the password field may stay empty when
one is already saved.
Read pointers are tied to the message number. NullModem BBS uses the database ID for it from version 0.25.1, the same in every packet; before that every packet started at 1, so new mail could appear read. Read pointers from that time are dropped once on first start.
Inside the reader, f exchanges mail -- sends replies, fetches new
mail, switches to the new packet -- without freezing the window. s
opens the setup again, say after a password change. If the BBS refuses
the login, the reader takes you there by itself.
The password goes into the system's password store (Windows Credential
Manager, macOS Keychain, Secret Service on Linux). Where there is none,
the dialog writes it into the configuration file and says so.
NMR_PASSWORD_<ID> in the environment still takes precedence.
Or from source: go install github.com/midrei/nullmodem-reader/cmd/nmr@latest.
DRY_RUN=1 scripts/release.sh v0.2.0 # test and build into dist/, nothing else
GITHUB_TOKEN=… scripts/release.sh v0.2.0 # REMOTE=github if origin isn't GitHub
The script checks that main is clean and in sync with origin and
that go.mod has no replace, runs go vet and the tests, builds all
six targets with GOWORK=off -- that is, against the kit version in
go.mod, not a local checkout -- and packs them with SHA256SUMS. Only
then does it set the tag, push it and create the release with the
archives on GitHub. GITHUB_TOKEN is a personal access token allowed
to write the repository's contents (fine-grained: Contents
read/write).
If the reader needs a new kit version, that comes first -- see Releases in the kit's README.
go run ./tools/mkfixture testdata/SAMPLE.QWK
go run ./cmd/nmr open testdata/SAMPLE.QWK # in the terminal
go run ./cmd/nmr gui testdata/SAMPLE.QWK # in its own window
Keys: ↑↓/jk move, Enter opens, Esc/q back, w welcome
screen, n/p next/previous message (after a conference's last one
back to the list, on the next one with unread mail), space pages and
at the end moves on to the next message, r reply, e new message,
N new netmail, m mark conference read, o queue, f exchange
mail, s setup, ? help, Q quit.
Without full screen:
go run ./cmd/nmr list testdata/SAMPLE.QWK
go run ./cmd/nmr screen testdata/SAMPLE.QWK
go run ./cmd/nmr read testdata/SAMPLE.QWK
Against a real BBS:
go run ./cmd/nmr gui # setup dialog, then the first mail
or with no window at all:
go run ./cmd/nmr init # writes an example configuration
export NMR_PASSWORD_NULLMDM=… # the password not in the file
go run ./cmd/nmr fetch
go run ./cmd/nmr open # all fetched packets together
Automatically in the background, with poll: per system in the
configuration:
go run ./cmd/nmr daemon
The daemon exchanges right at start and then at its interval. Running
nmr fetch by hand alongside is safe: both take the same lock, the
second says so and does nothing.
withQWKEKludges in NullModem BBS wrote TO:/FROM:/SUBJ: into the
message text. Those, however, are the labels of the header fields in
the QWKE 1.02 specification's example; the kludges themselves are
To:, From:, Subject:. No reader true to the specification
(MultiMail, NoCarrierMail) would have recognised the old lines -- it
would have shown the header fields cut to 25 bytes, and the kludge
lines as text on top.
That's fixed in the kit. ParseQWKEKludges still accepts both spellings
when reading, so packets from older NullModem versions stay readable.
Second, EncodeCP437 silently replaced everything unknown with ?. A
Mac keyboard produces typographic quotes, dashes and ellipses by itself
-- they became question marks without anyone noticing. Now the
function transliterates them to ASCII, and EncodeCP437Report names
what really couldn't be carried over.
The GUI embeds a CP437 8×16 bitmap font made from
Spleen (BSD-2-Clause, copyright
Frederic Cambus -- license text in assets/font/LICENSE.spleen).
tools/mkfont turns Spleen's CP437 BDF into the flat VGA ROM format, so
it stays traceable where the 4096 bytes come from:
go run ./tools/mkfont spleen-8x16-ibm-437.bdf assets/font/cp437-8x16.bin
An outline font wouldn't do here: block graphics must tile seamlessly at every scale, and only a bitmap font with integer scaling can promise that.
MIT -- see LICENSE. The embedded Spleen font is under
BSD-2-Clause (assets/font/LICENSE.spleen); both licenses are in every
release archive.