Message Boards and Mail: Persistent Communication in Phosphor BBS

June 19, 2026

A BBS without message boards is just a chat room. Boards are where the community lives — threaded discussions that persist over days, weeks, months. You log in, catch up on what you missed, reply to threads, and start new ones. It’s asynchronous communication at its best.

Mail is the private counterpart — user-to-user messages that nobody else can read. Together, boards and mail form the communication backbone of the BBS.

Phosphor BBS implements both as full terminal screens, backed by a Postgres (or H2) database, with live updates and group-based access control.

The Board Hierarchy

Boards follow a three-level navigation: Board List → Thread List → Thread View.

Board List shows all boards the user has access to. Boards have read groups and write groups — if a user isn’t in the read group, the board is invisible:

┌─ Message Boards ────────────────────────────────────────┐
│ Board              Threads  Last Post                    │
│ General Discussion    142    2026-06-19 14:23            │
│ Tech Talk              87    2026-06-19 12:01            │
│ ANSI Art Showcase      34    2026-06-18 22:15            │
│ VIP Lounge             12    2026-06-19 09:45            │
└──────────────────────────────────────────────────────────┘

Press Enter on a board to see its threads.

Thread List shows the subjects, authors, reply counts, and last activity for each thread in the board:

public class ThreadListScreen extends ContentScreen {
    private final Table table;
    private final List<Thread> threads = new ArrayList<>();

    public ThreadListScreen(BbsSession session, BoardService boardService, Board board) {
        super(board.name());
        this.table = new Table("Subject", "Author", "Replies", "Last Reply");

        // Live refresh: when a new post is created, refresh thread list
        postListener = post -> refresh();
        boardService.addPostListener(postListener);

        // Live refresh: when a thread is created or deleted, refresh
        threadListChangeListener = this::refresh;
        boardService.addThreadListChangeListener(threadListChangeListener);
    }
}

The key detail here is the live refresh. When another user posts a reply to a thread, the thread list updates automatically — the reply count increments, the last-activity timestamp changes, and the thread may reorder. No manual refresh, no polling. The BoardService fires post listener and thread list change listener events, and the screen reacts.

Thread View shows the posts in a thread, one after another, with author, date, and body:

┌─ Thread: Best ANSI art tools? ──────────────────────────┐
│                                                         │
│ [alice · 2026-06-18 22:15]                              │
│ What's everyone using for ANSI art these days?          │
│ I've been using Moebius but it feels limited.            │
│                                                         │
│ [bob · 2026-06-19 09:30]                                │
│ Check out PabloDraw — it's open source and supports     │
│ both ANSI and ASCII. The animation support is great.     │
│                                                         │
│ [charlie · 2026-06-19 14:23]                            │
│ Second PabloDraw. Also, the BBS has a built-in ANSI     │
│ art viewer now — just upload to the file area.          │
│                                                         │
├─ [R] Reply  [N] Next  [P] Prev  [Q] Back ────────────────┤
└──────────────────────────────────────────────────────────┘

Press R to reply. The compose dialog opens, you type your message, and it’s posted to the thread. The new post appears in the thread view immediately for everyone viewing it — the postListener fires and the screen refreshes.

Group-Based Access Control

Every board has two permission sets:

  • Read groups — which groups can see and read the board
  • Write groups — which groups can post new threads and replies

A board with read_groups = ["vip"] and write_groups = ["vip", "sysop"] is visible only to VIP users, and both VIP and sysop can post. Regular users don’t see the board in the list at all.

This is enforced at the database query level — the board list screen only fetches boards where the user’s groups intersect the read groups:

// In BoardService
public List<Board> boardsForUser(String username, List<String> groups) {
    return boards.stream()
            .filter(b -> hasReadAccess(b, groups))
            .toList();
}

Mail: Private Messages

Mail works alongside boards but is fundamentally different — it’s one-to-one, not one-to-many. The mail inbox shows all messages addressed to the current user:

┌─ Mail Inbox ────────────────────────────────────────────┐
│ From           Subject                  Date            │
│ alice          Re: project update       2026-06-19 14:23│
│ sysop          Welcome to the BBS       2026-06-18 09:00│
│ bob            ANSI art trade?          2026-06-17 22:15│
└──────────────────────────────────────────────────────────┘

Press Enter to read a message. The mail view screen shows the full message with headers and body:

public class MailViewScreen extends ContentScreen {
    @Override
    protected Component createContent() {
        var contentPanel = new Panel(new BorderLayout());

        // Header
        var headerLabel = new Label(
                "From: " + message.fromUser() + "@" + message.fromHost() + "\n" +
                "Date: " + DATE_FMT.format(message.createdAt()) + "\n" +
                "Subject: " + message.subject()
        );
        contentPanel.addComponent(headerLabel,
                new BorderLayout.BorderLayoutData(BorderLayout.Region.NORTH));

        // Body
        var bodyBox = new ListBox<String>();
        bodyBox.setAutoScroll(true);
        String body = message.body();
        String[] lines = body.split("\n", -1);
        for (String line : lines) {
            // Word-wrap at 76 columns
            for (int idx = 0; idx < line.length(); idx += 76) {
                bodyBox.addItem(line.substring(idx, Math.min(idx + 76, line.length())));
            }
        }
        contentPanel.addComponent(bodyBox,
                new BorderLayout.BorderLayoutData(BorderLayout.Region.CENTER));

        return contentPanel;
    }
}

Press R to reply. The compose dialog opens pre-filled with the sender’s address and a “Re:” subject. You type your reply, press Enter, and it’s sent.

The Compose Dialog

Both board replies and mail replies use the same compose dialog — a reusable jterm widget with subject and body fields:

public class MailComposeDialog extends WindowImpl {
    private final TextBox subjectBox;
    private final TextBox bodyBox;

    public MailComposeDialog(BbsSession session, String title,
                              String prefillTo, String prefillSubject) {
        super(title);
        // ... build UI with subject field, body area, send/cancel buttons
    }

    public MailComposeResult getResult() {
        return new MailComposeResult(subjectBox.getText(), bodyBox.getText());
    }
}

The body field is a multi-line text box with word wrapping. The subject is a single-line input. Tab moves between fields. Enter sends (with a confirmation dialog), Escape cancels.

Live Updates: The Listener Pattern

Both boards and mail use the same live-update pattern. When a new post or mail message is created, the service fires a listener event, and any open screen that cares about that data refreshes:

// In BoardService
private final List<Consumer<Post>> postListeners = new CopyOnWriteArrayList<>();

public void addPostListener(Consumer<Post> listener) {
    postListeners.add(listener);
}

public Post createPost(String username, String body, UUID threadId) {
    // ... save to database ...
    Post post = new Post(...);
    for (var listener : postListeners) {
        listener.accept(post);
    }
    return post;
}

This means: if you’re viewing a thread and someone else replies, the new post appears in your view immediately. If you’re looking at the thread list and a new thread is created, it shows up in the list. No refresh button, no polling, no relogin.

The same pattern applies to mail — when a new message arrives, the inbox refreshes. The user sees the new message appear in the list while they’re looking at it.

Why This Matters

Message boards and mail are the asynchronous communication layer of the BBS. Chat is real-time, but boards and mail persist. You can log off, come back tomorrow, and pick up where you left off — read the threads that were posted while you were away, reply to mail, start new discussions.

This is what made BBSes special in the 90s, and it’s what makes them special now. The conversation doesn’t disappear when you log off. It’s there, waiting, organized by thread and board. The community builds up over time, message by message.

Phosphor BBS brings that back — with live updates, group-based access, and a terminal interface that feels like home.