From 737a26931a707d4ce366cc004f3b879e39e50237 Mon Sep 17 00:00:00 2001
From: Kevin Lyda <kevin@lyda.ie>
Date: Sat, 10 May 2025 23:29:56 +0100
Subject: [PATCH] First pass at storage.

---
 Makefile                                 |  7 ++
 ask/ask.go                               | 13 ++++
 storage/connection.go                    | 50 +-------------
 storage/display.go                       | 35 ++++++++++
 storage/folders.sql.go                   |  5 +-
 storage/messages.sql.go                  | 36 ++++++----
 storage/migrate.go                       | 35 ++++++++++
 storage/migrations/1_create_table.up.sql | 60 ++++++++++-------
 storage/models.go                        | 35 +++++-----
 storage/queries/messages.sql             |  3 +
 storage/queries/users.sql                |  7 +-
 storage/users.sql.go                     | 39 +++++++----
 this/this.go                             | 85 ++++++++++++++++++++++++
 users/users.go                           | 81 +---------------------
 14 files changed, 291 insertions(+), 200 deletions(-)
 create mode 100644 Makefile
 create mode 100644 storage/display.go
 create mode 100644 storage/migrate.go

diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..8ac08fb
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,7 @@
+.PHONY: all
+all:
+	go generate ./...
+
+
+# vim:ft=make
+#
diff --git a/ask/ask.go b/ask/ask.go
index 30ddba7..1626a1c 100644
--- a/ask/ask.go
+++ b/ask/ask.go
@@ -3,3 +3,16 @@ Package ask provides routines to ask questions of users. It handles
 getting a line of text, getting a choice from a liat and other things.
 */
 package ask
+
+import "github.com/chzyer/readline"
+
+// GetLine gets a line.
+func GetLine(prompt string) (string, error) {
+	rl, err := readline.New(prompt)
+	if err != nil {
+		return "", err
+	}
+	defer rl.Close()
+	line, err := rl.Readline()
+	return line, err
+}
diff --git a/storage/connection.go b/storage/connection.go
index 0b7a8c7..fcf7c97 100644
--- a/storage/connection.go
+++ b/storage/connection.go
@@ -1,14 +1,8 @@
 package storage
 
 import (
-	"embed"
 	"errors"
-	"os"
-	"path"
 
-	"github.com/adrg/xdg"
-	"github.com/golang-migrate/migrate/v4"
-	"github.com/golang-migrate/migrate/v4/source/iofs"
 	"github.com/jmoiron/sqlx"
 
 	// Included to connect to sqlite.
@@ -16,49 +10,11 @@ import (
 	_ "modernc.org/sqlite"
 )
 
-//go:embed migrations/*.sql
-var migrationsFS embed.FS
-
-// Store is the store for bulletin.
-type Store struct {
-	user string
-	db   *sqlx.DB
-}
-
 // Open opens the bulletin database.
-func Open(user string) (*Store, error) {
-	fdir := path.Join(xdg.DataHome, "BULLETIN")
-	err := os.MkdirAll(fdir, 0700)
-	if err != nil {
-		return nil, errors.New("bulletin directory problem")
-	}
-	fdb := path.Join(fdir, "bulletin.db")
-
-	// Run db migrations if needed.
-	migrations, err := iofs.New(migrationsFS, "migrations")
-	if err != nil {
-		return nil, err
-	}
-	m, err := migrate.NewWithSourceInstance("iofs", migrations,
-		"sqlite://"+fdb+"?_pragma=foreign_keys(1)")
-	if err != nil {
-		return nil, err
-	}
-	err = m.Up()
-	if err != nil && err != migrate.ErrNoChange {
-		return nil, err
-	}
-	m.Close()
-
-	store := &Store{user: user}
-	store.db, err = sqlx.Connect("sqlite", "file://"+fdb+"?_pragma=foreign_keys(1)")
+func Open(dbfile string) (*sqlx.DB, error) {
+	db, err := sqlx.Connect("sqlite", "file://"+dbfile+"?_pragma=foreign_keys(1)")
 	if err != nil {
 		return nil, errors.New("bulletin database problem")
 	}
-	return store, nil
-}
-
-// Close closes the db backing the store.
-func (fstore *Store) Close() {
-	fstore.db.Close()
+	return db, nil
 }
diff --git a/storage/display.go b/storage/display.go
new file mode 100644
index 0000000..11d62f7
--- /dev/null
+++ b/storage/display.go
@@ -0,0 +1,35 @@
+package storage
+
+import (
+	"fmt"
+	"strings"
+	"time"
+)
+
+// Full renders a message.
+func (m *Message) Full() string {
+	buf := &strings.Builder{}
+	changed := "*"
+	if m.CreateAt.Compare(m.UpdateAt) == 0 {
+		changed = ""
+	}
+	fmt.Fprintf(buf, "From: \"%s\" %s%s\n", m.Author,
+		m.CreateAt.Format("02-JAN-2006 15:04:05"), changed)
+	fmt.Fprintf(buf, "To: %s\n", m.Folder)
+	fmt.Fprintf(buf, "Subj: %s\n\n", m.Subject)
+	fmt.Fprintf(buf, "%s\n", m.Message)
+
+	return buf.String()
+}
+
+// Short renders a message.
+func (m *Message) Short(expire bool) string {
+	var t time.Time
+	if expire {
+		t = m.Expiration
+	} else {
+		t = m.CreateAt
+	}
+	ts := t.Format("2006-05-04 15:02:01")
+	return fmt.Sprintf("%4d %-43s %-12s %-10s\n", m.ID, m.Subject, m.Author, ts)
+}
diff --git a/storage/folders.sql.go b/storage/folders.sql.go
index c2df48f..00286ee 100644
--- a/storage/folders.sql.go
+++ b/storage/folders.sql.go
@@ -7,7 +7,6 @@ package storage
 
 import (
 	"context"
-	"database/sql"
 )
 
 const createFolder = `-- name: CreateFolder :exec
@@ -97,7 +96,7 @@ SELECT 1 FROM folders AS f LEFT JOIN owners AS c ON f.name = c.folder
 
 type IsFolderAccessParams struct {
 	Name  string
-	Owner sql.NullString
+	Owner string
 }
 
 func (q *Queries) IsFolderAccess(ctx context.Context, arg IsFolderAccessParams) (int64, error) {
@@ -114,7 +113,7 @@ SELECT 1 FROM folders AS f LEFT JOIN owners AS c ON f.name = c.folder
 
 type IsFolderOwnerParams struct {
 	Name  string
-	Owner sql.NullString
+	Owner string
 }
 
 func (q *Queries) IsFolderOwner(ctx context.Context, arg IsFolderOwnerParams) (int64, error) {
diff --git a/storage/messages.sql.go b/storage/messages.sql.go
index 62b82a4..0b8d4d9 100644
--- a/storage/messages.sql.go
+++ b/storage/messages.sql.go
@@ -7,7 +7,6 @@ package storage
 
 import (
 	"context"
-	"database/sql"
 	"time"
 )
 
@@ -20,9 +19,9 @@ INSERT INTO messages (
 `
 
 type CreateMessageParams struct {
-	Folder     sql.NullString
-	Folder_2   sql.NullString
-	Author     sql.NullString
+	Folder     string
+	Folder_2   string
+	Author     string
 	Subject    string
 	Message    string
 	Permanent  int64
@@ -44,6 +43,17 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) er
 	return err
 }
 
+const getFirstMessageID = `-- name: GetFirstMessageID :one
+SELECT id FROM messages WHERE folder = ? and id = MIN(id) GROUP BY folder
+`
+
+func (q *Queries) GetFirstMessageID(ctx context.Context, folder string) (int64, error) {
+	row := q.db.QueryRowContext(ctx, getFirstMessageID, folder)
+	var id int64
+	err := row.Scan(&id)
+	return id, err
+}
+
 const listMessages = `-- name: ListMessages :many
 SELECT id, folder, author, subject, message, expiration, create_at, update_at
 FROM messages
@@ -52,8 +62,8 @@ WHERE folder = ?
 
 type ListMessagesRow struct {
 	ID         int64
-	Folder     sql.NullString
-	Author     sql.NullString
+	Folder     string
+	Author     string
 	Subject    string
 	Message    string
 	Expiration time.Time
@@ -61,7 +71,7 @@ type ListMessagesRow struct {
 	UpdateAt   time.Time
 }
 
-func (q *Queries) ListMessages(ctx context.Context, folder sql.NullString) ([]ListMessagesRow, error) {
+func (q *Queries) ListMessages(ctx context.Context, folder string) ([]ListMessagesRow, error) {
 	rows, err := q.db.QueryContext(ctx, listMessages, folder)
 	if err != nil {
 		return nil, err
@@ -98,9 +108,9 @@ INSERT INTO mark (login, folder, msgid) VALUES (?, ?, ?)
 `
 
 type MarkMessageParams struct {
-	Login  sql.NullString
-	Folder sql.NullString
-	Msgid  sql.NullInt64
+	Login  string
+	Folder string
+	Msgid  int64
 }
 
 func (q *Queries) MarkMessage(ctx context.Context, arg MarkMessageParams) error {
@@ -113,9 +123,9 @@ INSERT INTO seen (login, folder, msgid) VALUES (?, ?, ?)
 `
 
 type SetMessageSeenParams struct {
-	Login  sql.NullString
-	Folder sql.NullString
-	Msgid  sql.NullInt64
+	Login  string
+	Folder string
+	Msgid  int64
 }
 
 func (q *Queries) SetMessageSeen(ctx context.Context, arg SetMessageSeenParams) error {
diff --git a/storage/migrate.go b/storage/migrate.go
new file mode 100644
index 0000000..ab8f218
--- /dev/null
+++ b/storage/migrate.go
@@ -0,0 +1,35 @@
+package storage
+
+import (
+	"embed"
+
+	"github.com/golang-migrate/migrate/v4"
+	"github.com/golang-migrate/migrate/v4/source/iofs"
+
+	// Included to connect to sqlite.
+	_ "github.com/golang-migrate/migrate/v4/database/sqlite"
+	_ "modernc.org/sqlite"
+)
+
+//go:embed migrations/*.sql
+var migrationsFS embed.FS
+
+// Migrate creates and updates the database.
+func Migrate(dbfile string) error {
+	// Run db migrations if needed.
+	migrations, err := iofs.New(migrationsFS, "migrations")
+	if err != nil {
+		return err
+	}
+	m, err := migrate.NewWithSourceInstance("iofs", migrations,
+		"sqlite://"+dbfile+"?_pragma=foreign_keys(1)")
+	if err != nil {
+		return err
+	}
+	defer m.Close()
+	err = m.Up()
+	if err != nil && err != migrate.ErrNoChange {
+		return err
+	}
+	return nil
+}
diff --git a/storage/migrations/1_create_table.up.sql b/storage/migrations/1_create_table.up.sql
index 0aafda9..1329304 100644
--- a/storage/migrations/1_create_table.up.sql
+++ b/storage/migrations/1_create_table.up.sql
@@ -1,5 +1,5 @@
 CREATE TABLE users (
-  login       VARCHAR(12)  NOT NULL PRIMARY KEY,
+  login       VARCHAR(12)  PRIMARY KEY NOT NULL,
   name        VARCHAR(53)  NOT NULL,
   admin       INT          DEFAULT 0 NOT NULL,
   moderator   INT          DEFAULT 0 NOT NULL,
@@ -25,14 +25,14 @@ BEGIN
 END;
 
 CREATE TRIGGER users_before_delete_protect
-  BEFORE DELETE on users FOR EACH ROW
+  BEFORE DELETE ON users FOR EACH ROW
   WHEN OLD.login = 'SYSTEM'
 BEGIN
   SELECT RAISE (ABORT, 'SYSTEM user is protected');
 END;
 
 CREATE TABLE folders (
-  name        VARCHAR(25)  NOT NULL PRIMARY KEY,
+  name        VARCHAR(25)  PRIMARY KEY NOT NULL,
   always      INT          DEFAULT 0 NOT NULL,
   brief       INT          DEFAULT 0 NOT NULL,
   description VARCHAR(53)  DEFAULT 0 NOT NULL,
@@ -84,8 +84,10 @@ BEGIN
 END;
 
 CREATE TABLE owners (
-  folder      VARCHAR(25)  REFERENCES folders(name) ON DELETE CASCADE ON UPDATE CASCADE,
-  owner       VARCHAR(25)  REFERENCES users(login) ON UPDATE CASCADE,
+  folder      VARCHAR(25)  REFERENCES folders(name)
+                           ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
+  owner       VARCHAR(25)  REFERENCES users(login)
+                           ON UPDATE CASCADE NOT NULL,
   create_at   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
   update_at   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
   PRIMARY KEY (folder, owner)
@@ -100,8 +102,12 @@ END;
 
 CREATE TABLE messages (
   id          INT          NOT NULL,
-  folder      VARCHAR(25)  REFERENCES folders(name) ON DELETE CASCADE ON UPDATE CASCADE,
-  author      VARCHAR(25)  REFERENCES users(login) ON UPDATE CASCADE,
+  folder      VARCHAR(25)  REFERENCES folders(name)
+                           ON DELETE CASCADE ON UPDATE CASCADE
+                           NOT NULL,
+  author      VARCHAR(25)  REFERENCES users(login)
+                           ON UPDATE CASCADE
+                           NOT NULL,
   subject     VARCHAR(53)  NOT NULL,
   message     TEXT         NOT NULL,
   permanent   INT          DEFAULT 0 NOT NULL,
@@ -115,9 +121,11 @@ CREATE INDEX messages_idx_shutdown ON messages(shutdown);
 CREATE INDEX messages_idx_expiration ON messages(expiration);
 
 CREATE TABLE seen (
-  login       VARCHAR(25) REFERENCES users(login) ON DELETE CASCADE ON UPDATE CASCADE,
-  folder      VARCHAR(25) REFERENCES folders(name) ON DELETE CASCADE ON UPDATE CASCADE,
-  msgid       INT,
+  login       VARCHAR(25) REFERENCES users(login)
+              ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
+  folder      VARCHAR(25) REFERENCES folders(name)
+              ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
+  msgid       INT     NOT NULL,
   PRIMARY KEY (folder, login, msgid),
   CONSTRAINT read_fk_id_folder
     FOREIGN KEY (msgid, folder)
@@ -127,9 +135,11 @@ CREATE TABLE seen (
 ) WITHOUT ROWID;
 
 CREATE TABLE mark (
-  login       VARCHAR(25) REFERENCES users(login) ON DELETE CASCADE ON UPDATE CASCADE,
-  folder      VARCHAR(25) REFERENCES folders(name) ON DELETE CASCADE ON UPDATE CASCADE,
-  msgid       INT,
+  login       VARCHAR(25) REFERENCES users(login)
+              ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
+  folder      VARCHAR(25) REFERENCES folders(name)
+              ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
+  msgid       INT     NOT NULL,
   PRIMARY KEY (folder, login, msgid),
   CONSTRAINT mark_fk_id_folder
     FOREIGN KEY (msgid, folder)
@@ -138,24 +148,28 @@ CREATE TABLE mark (
     ON UPDATE CASCADE
 ) WITHOUT ROWID;
 
-CREATE TABLE access (
-  login       VARCHAR(25) REFERENCES users(login) ON DELETE CASCADE ON UPDATE CASCADE,
-  folder      VARCHAR(25) REFERENCES folders(name) ON DELETE CASCADE ON UPDATE CASCADE,
+CREATE TABLE folder_access (
+  login       VARCHAR(25) REFERENCES users(login)
+              ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
+  folder      VARCHAR(25) REFERENCES folders(name)
+              ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
   PRIMARY KEY (login, folder)
 ) WITHOUT ROWID;
 
 --- User folder configs.
 CREATE TABLE config (
-  login       VARCHAR(25) REFERENCES users(login) ON DELETE CASCADE ON UPDATE CASCADE,
-  folder      VARCHAR(25) REFERENCES folders(name) ON DELETE CASCADE ON UPDATE CASCADE,
-  always      INT     NOT NULL DEFAULT 0,
-  alert       INT     NOT NULL DEFAULT 0,  --- 0=no, 1=brief, 2=readnew
+  login       VARCHAR(25) REFERENCES users(login)
+              ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
+  folder      VARCHAR(25) REFERENCES folders(name)
+              ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
+  always      INT     DEFAULT 0 NOT NULL,
+  alert       INT     DEFAULT 0 NOT NULL,  --- 0=no, 1=brief, 2=readnew
   PRIMARY KEY (login, folder)
 ) WITHOUT ROWID;
 
 --- System configs.
 CREATE TABLE system (
-  name            VARCHAR(12)  NOT NULL PRIMARY KEY,
-  default_expire  INT          NOT NULL DEFAULT -1,
-  expire_limit    INT          NOT NULL DEFAULT -1
+  name            VARCHAR(12)  PRIMARY KEY NOT NULL,
+  default_expire  INT          DEFAULT -1 NOT NULL,
+  expire_limit    INT          DEFAULT -1 NOT NULL
 );
diff --git a/storage/models.go b/storage/models.go
index 13279ef..cfcd898 100644
--- a/storage/models.go
+++ b/storage/models.go
@@ -5,18 +5,12 @@
 package storage
 
 import (
-	"database/sql"
 	"time"
 )
 
-type Access struct {
-	Login  sql.NullString
-	Folder sql.NullString
-}
-
 type Config struct {
-	Login  sql.NullString
-	Folder sql.NullString
+	Login  string
+	Folder string
 	Always int64
 	Alert  int64
 }
@@ -36,16 +30,21 @@ type Folder struct {
 	UpdateAt    time.Time
 }
 
+type FolderAccess struct {
+	Login  string
+	Folder string
+}
+
 type Mark struct {
-	Login  sql.NullString
-	Folder sql.NullString
-	Msgid  sql.NullInt64
+	Login  string
+	Folder string
+	Msgid  int64
 }
 
 type Message struct {
 	ID         int64
-	Folder     sql.NullString
-	Author     sql.NullString
+	Folder     string
+	Author     string
 	Subject    string
 	Message    string
 	Permanent  int64
@@ -56,16 +55,16 @@ type Message struct {
 }
 
 type Owner struct {
-	Folder   sql.NullString
-	Owner    sql.NullString
+	Folder   string
+	Owner    string
 	CreateAt time.Time
 	UpdateAt time.Time
 }
 
 type Seen struct {
-	Login  sql.NullString
-	Folder sql.NullString
-	Msgid  sql.NullInt64
+	Login  string
+	Folder string
+	Msgid  int64
 }
 
 type System struct {
diff --git a/storage/queries/messages.sql b/storage/queries/messages.sql
index 2f87c68..07dcf67 100644
--- a/storage/queries/messages.sql
+++ b/storage/queries/messages.sql
@@ -15,3 +15,6 @@ INSERT INTO mark (login, folder, msgid) VALUES (?, ?, ?);
 SELECT id, folder, author, subject, message, expiration, create_at, update_at
 FROM messages
 WHERE folder = ?;
+
+-- name: GetFirstMessageID :one
+SELECT id FROM messages WHERE folder = ? and id = MIN(id) GROUP BY folder;
diff --git a/storage/queries/users.sql b/storage/queries/users.sql
index 50ca0c6..1ee85d5 100644
--- a/storage/queries/users.sql
+++ b/storage/queries/users.sql
@@ -1,8 +1,9 @@
 -- name: GetUser :one
-SELECT login, name, admin, disabled FROM users WHERE login = ?;
+SELECT * FROM users WHERE login = ?;
 
--- name: AddUser :exec
-INSERT INTO users (login, name, admin) VALUES (?, ?, ?);
+-- name: AddUser :one
+INSERT INTO users (login, name, admin) VALUES (?, ?, ?)
+RETURNING *;
 
 -- name: IsUserAdmin :one
 SELECT admin FROM users WHERE login = ?;
diff --git a/storage/users.sql.go b/storage/users.sql.go
index d51be82..ae1cad7 100644
--- a/storage/users.sql.go
+++ b/storage/users.sql.go
@@ -9,8 +9,9 @@ import (
 	"context"
 )
 
-const addUser = `-- name: AddUser :exec
+const addUser = `-- name: AddUser :one
 INSERT INTO users (login, name, admin) VALUES (?, ?, ?)
+RETURNING login, name, admin, moderator, alert, disabled, last_login, create_at, update_at
 `
 
 type AddUserParams struct {
@@ -19,30 +20,40 @@ type AddUserParams struct {
 	Admin int64
 }
 
-func (q *Queries) AddUser(ctx context.Context, arg AddUserParams) error {
-	_, err := q.db.ExecContext(ctx, addUser, arg.Login, arg.Name, arg.Admin)
-	return err
+func (q *Queries) AddUser(ctx context.Context, arg AddUserParams) (User, error) {
+	row := q.db.QueryRowContext(ctx, addUser, arg.Login, arg.Name, arg.Admin)
+	var i User
+	err := row.Scan(
+		&i.Login,
+		&i.Name,
+		&i.Admin,
+		&i.Moderator,
+		&i.Alert,
+		&i.Disabled,
+		&i.LastLogin,
+		&i.CreateAt,
+		&i.UpdateAt,
+	)
+	return i, err
 }
 
 const getUser = `-- name: GetUser :one
-SELECT login, name, admin, disabled FROM users WHERE login = ?
+SELECT login, name, admin, moderator, alert, disabled, last_login, create_at, update_at FROM users WHERE login = ?
 `
 
-type GetUserRow struct {
-	Login    string
-	Name     string
-	Admin    int64
-	Disabled int64
-}
-
-func (q *Queries) GetUser(ctx context.Context, login string) (GetUserRow, error) {
+func (q *Queries) GetUser(ctx context.Context, login string) (User, error) {
 	row := q.db.QueryRowContext(ctx, getUser, login)
-	var i GetUserRow
+	var i User
 	err := row.Scan(
 		&i.Login,
 		&i.Name,
 		&i.Admin,
+		&i.Moderator,
+		&i.Alert,
 		&i.Disabled,
+		&i.LastLogin,
+		&i.CreateAt,
+		&i.UpdateAt,
 	)
 	return i, err
 }
diff --git a/this/this.go b/this/this.go
index 40cfe11..a5ea9d4 100644
--- a/this/this.go
+++ b/this/this.go
@@ -8,3 +8,88 @@ folder, the current message id and other things.
 TODO: Eventually `this` will need to handle broadcast messages.
 */
 package this
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"os"
+	"path"
+
+	"git.lyda.ie/kevin/bulletin/ask"
+	"git.lyda.ie/kevin/bulletin/storage"
+	"git.lyda.ie/kevin/bulletin/users"
+	"github.com/adrg/xdg"
+	"github.com/jmoiron/sqlx"
+)
+
+// User is the user for this session.
+var User storage.User
+
+// DBFile is the path for the storage.
+var DBFile string
+
+// Store is the store for this session.
+var Store *sqlx.DB
+
+// Q is the storage.Queries for this session.
+var Q *storage.Queries
+
+// Folder is the current folder.
+var Folder string
+
+// MsgID is the current message id.
+var MsgID int64
+
+// StartSession starts a session.
+func StartSession(login, name string) error {
+	// Validate the login name.
+	err := users.ValidLogin(login)
+	if err != nil {
+		return err
+	}
+
+	// Run migrations.
+	bulldir := path.Join(xdg.DataHome, "BULLETIN")
+	err = os.MkdirAll(bulldir, 0700)
+	if err != nil {
+		return errors.New("bulletin directory problem")
+	}
+	DBFile := path.Join(bulldir, "bulletin.db")
+	storage.Migrate(DBFile)
+
+	Store, err = storage.Open(DBFile)
+	if err != nil {
+		return err
+	}
+	Q = storage.New(Store.DB)
+
+	ctx := context.TODO()
+	User, err = Q.GetUser(ctx, login)
+
+	if User.Login != login {
+		if name == "" {
+			fmt.Printf("Welcome new user %s\n", login)
+			name, err = ask.GetLine("please enter your name: ")
+			if err != nil {
+				return err
+			}
+		}
+
+		User, err = Q.AddUser(ctx, storage.AddUserParams{
+			Login: login,
+			Name:  name,
+		})
+		if err != nil {
+			return err
+		}
+		fmt.Println("User successfully created. Enjoy!")
+	}
+	if User.Disabled == 1 {
+		return errors.New("User is disabled")
+	}
+	Folder = "GENERAL"
+	MsgID, err = Q.GetFirstMessageID(ctx, Folder)
+
+	return nil
+}
diff --git a/users/users.go b/users/users.go
index 4b2c99f..ecbf8ef 100644
--- a/users/users.go
+++ b/users/users.go
@@ -6,26 +6,11 @@ import (
 	"fmt"
 	"strings"
 
-	"git.lyda.ie/kevin/bulletin/folders"
-	"github.com/chzyer/readline"
 	_ "modernc.org/sqlite" // Loads sqlite driver.
 )
 
-// UserData is the type for holding user data. Things like preferences,
-// unread message counts, signatures, etc.
-type UserData struct {
-	Login          string
-	FullName       string
-	Folders        *folders.Store
-	CurrentFolder  string
-	CurrentMessage int
-}
-
-// User is the user for this process.  It is loaded by the `Verify` function.
-var User *UserData
-
-// ValidName makes sure that an account name is a valid name.
-func ValidName(login string) error {
+// ValidLogin makes sure that an account name is a valid name.
+func ValidLogin(login string) error {
 	if login == "" {
 		return errors.New("empty account is invalid")
 	}
@@ -34,65 +19,3 @@ func ValidName(login string) error {
 	}
 	return nil
 }
-
-// Open verifies that an account exists.
-func Open(login, name string) error {
-	err := ValidName(login)
-	if err != nil {
-		return err
-	}
-	User = &UserData{
-		Login: login,
-	}
-
-	User.Folders, err = folders.Open(login)
-	if err != nil {
-		return err
-	}
-	user, _ := User.Folders.GetUser(login)
-
-	if user.Login != login {
-		user.Login = login
-		user.Admin = 0
-
-		user.Name = name
-		if name == "" {
-			fmt.Printf("Welcome new user %s\n", login)
-			user.Name, err = GetLine("please enter your name: ")
-			if err != nil {
-				return err
-			}
-		}
-
-		err = User.Folders.AddUser(*user)
-		if err != nil {
-			return err
-		}
-		fmt.Println("User successfully created. Enjoy!")
-	}
-	if user.Disabled == 1 {
-		return errors.New("User is disabled")
-	}
-	User.FullName = user.Name
-	User.CurrentFolder = "GENERAL"
-	// TODO: get the most recent unread message.
-	// User.CurrentMessage =
-
-	return nil
-}
-
-// Close closes the resources open for the account.
-func (u *UserData) Close() {
-	u.Folders.Close()
-}
-
-// GetLine gets a line.
-func GetLine(prompt string) (string, error) {
-	rl, err := readline.New(prompt)
-	if err != nil {
-		return "", err
-	}
-	defer rl.Close()
-	line, err := rl.Readline()
-	return line, err
-}
-- 
GitLab