diff --git a/NOTES.md b/NOTES.md
index 416091da9a4869c3909be5de12c0e0c78140f1df..f6ce8a1a3cf06b2e7986e387fa5298de18a38cf9 100644
--- a/NOTES.md
+++ b/NOTES.md
@@ -21,7 +21,7 @@ Switch between MAIL and BULLETIN modes? MAIL commands are documented
 ## Things to do
 
   * Run [godoc](http://localhost:6060/) and then review where the help text is lacking.
-  * Missing [MAIL] [RESPOND] [SET DEFAULT_EXPIRE] [SET READNEW] [SET NOREADNEW] [SET NOSHOWNEW] [SET NOPROMPT_EXPIRE] [SET EXPIRE_LIMIT] [SET PROMPT_EXPIRE] [SET SHOWNEW] [SHOW NEW] [SEARCH]
+  * Missing [RESPOND] [MAIL] [SET PROMPT_EXPIRE] [SET NOREADNEW] [SET NOSHOWNEW] [SET EXPIRE_LIMIT] [SET NOPROMPT_EXPIRE] [SET READNEW] [SET SHOWNEW] [SET DEFAULT_EXPIRE] [SHOW NEW]
   * Run this.Skew.Safe() before... each command?  each write?
   * Handle broadcast messages - create a broadcast table and add an expiration column.
   * Database
@@ -30,6 +30,9 @@ Switch between MAIL and BULLETIN modes? MAIL commands are documented
     * Commands for a local mail system?
     * Commands to connect to Mattermost or mastodon?
   * Make a spreadsheet for signups.
+  * Pager:
+    * Make / work for search.
+  * Run [VACUUM](https://www.sqlite.org/lang_vacuum.html) on the expire run.
 
 Polishing.  Review each command and put a + next to each as it is
 fully done.
@@ -40,8 +43,8 @@ Top level:
   ADD       +BACK       BULLETIN   CHANGE     COPY       CREATE
  +Ctrl-C    +CURRENT    DELETE     DIRECTORY +EXIT      +FIRST
  +Folders    FORWARD   +HELP       INDEX     +Keypad    +LAST
-  MAIL      +MARK       MODIFY     MOVE      +NEXT       PRINT
- +QUIT      +READ       REMOVE    +REPLY      RESPOND    SEARCH
+  MAIL      +MARK       MODIFY     MOVE      +NEXT      +PRINT
+ +QUIT      +READ       REMOVE    +REPLY      RESPOND   +SEARCH
   SEEN       SELECT    +SET       +SHOW      +SSH       +UNMARK
   UNSEEN    +USER
 
diff --git a/folders/folders.go b/folders/folders.go
index 7f2245ceeddda13656ef8e1a05bb9532120e4d37..ed5cae86fc1f0cd53066f06394cef3e6d0e8e625 100644
--- a/folders/folders.go
+++ b/folders/folders.go
@@ -90,6 +90,10 @@ func FindFolder(name string) storage.Folder {
 // IsFolderAccess checks if a user can access a folder.
 func IsFolderAccess(name, login string) bool {
 	ctx := storage.Context()
+	admin, _ := this.Q.IsUserAdmin(ctx, login)
+	if admin == 1 {
+		return true
+	}
 	found, _ := this.Q.IsFolderAccess(ctx, storage.IsFolderAccessParams{
 		Name:  name,
 		Login: login,
diff --git a/repl/command.go b/repl/command.go
index 43ee9340e5961bb89b3414602d5fdea03f6bd32c..7ed1ccb8d956005407f9cfa5ee62db97d2a3ef52 100644
--- a/repl/command.go
+++ b/repl/command.go
@@ -916,8 +916,9 @@ header. If  a "search-string" is not  specified, a search is  made using
 the previously specified string, starting with the message following the
 one  you are  currently reading  (or have  just read).  Once started,  a
 search can be aborted by typing a CTRL-C.`,
-		MinArgs: 1,
+		MinArgs: 0,
 		MaxArgs: 1,
+		Action:  ActionSearch,
 		Flags: dclish.Flags{
 			"/FOLDER": {
 				Description: `/FOLDER=(folder,[...])
diff --git a/repl/messages.go b/repl/messages.go
index ff2611fa55549dfdd6e779aec0794cb9d1923a7b..a0ffe012c4605845dca3464f9d97be164defd33a 100644
--- a/repl/messages.go
+++ b/repl/messages.go
@@ -13,6 +13,7 @@ import (
 	"git.lyda.ie/kevin/bulletin/editor"
 	"git.lyda.ie/kevin/bulletin/folders"
 	"git.lyda.ie/kevin/bulletin/pager"
+	"git.lyda.ie/kevin/bulletin/storage"
 	"git.lyda.ie/kevin/bulletin/this"
 )
 
@@ -101,7 +102,6 @@ func ActionAdd(cmd *dclish.Command) error {
 		optExtract = 1
 	}
 	if cmd.Flags["/FOLDER"].Value != "" {
-		fmt.Printf("/FOLDER = %s\n", cmd.Flags["/FOLDER"].Value)
 		optFolder = strings.Split(cmd.Flags["/FOLDER"].Value, ",")
 	}
 	if cmd.Flags["/INDENT"].Value == "true" {
@@ -253,14 +253,24 @@ func ActionNext(_ *dclish.Command) error {
 
 // ActionPrint handles the `PRINT` command.
 func ActionPrint(cmd *dclish.Command) error {
-	// TODO: handle flags.
+	all := false
+	if cmd.Flags["/ALL"].Value == "true" {
+		all = true
+	}
+
+	ctx := storage.Context()
 	msgids := []int64{this.MsgID}
+	var err error
 	if len(cmd.Args) == 1 {
-		var err error
+		if all {
+			return errors.New("Can't provide a message list and /ALL")
+		}
 		msgids, err = ParseNumberList(cmd.Args[0])
 		if err != nil {
 			return err
 		}
+	} else if all {
+		msgids, err = this.Q.ListMessageIDs(ctx, this.Folder.Name)
 	}
 	print("\033[5i")
 	for _, msgid := range msgids {
@@ -453,3 +463,159 @@ func ActionUnmark(cmd *dclish.Command) error {
 	}
 	return nil
 }
+
+// ActionSearch handles the `SEARCH` command.  This will show all messages
+// matching a search term.
+//
+// See subtoutines SEARCH and GET_SEARCH in bulletin2.for for the
+// original implementation.
+func ActionSearch(cmd *dclish.Command) error {
+	ctx := storage.Context()
+	var err error
+	optFolders := []string{this.Folder.Name}
+	if cmd.Flags["/FOLDER"].Value != "" {
+		optFolders = strings.Split(strings.ToUpper(cmd.Flags["/FOLDER"].Value), ",")
+		for i := range optFolders {
+			folder, _ := this.Q.FindFolderExact(ctx, optFolders[i])
+			if folder.Name != "" {
+				return fmt.Errorf("Folder '%s' not found", optFolders[i])
+			}
+			if folders.IsFolderAccess(optFolders[i], this.User.Login) {
+				return fmt.Errorf("Folder '%s' is not accessible", optFolders[i])
+			}
+		}
+	}
+
+	optReply := false
+	if cmd.Flags["/REPLY"].Value == "true" {
+		optReply = true
+	}
+
+	optReverse := false
+	if cmd.Flags["/REVERSE"].Value == "true" {
+		optReverse = true
+	}
+
+	optStart := int64(-1) // -1 means first message.
+	if optReverse {
+		optStart = 0 // 0 means last message.
+	}
+	if cmd.Flags["/START"].Set {
+		optStart, err = strconv.ParseInt(cmd.Flags["/START"].Value, 10, 64)
+		if err != nil {
+			return err
+		}
+		if optStart < 1 {
+			return errors.New("/START must be 1 or larger")
+		}
+	}
+
+	optSubject := false
+	if cmd.Flags["/SUBJECT"].Value == "true" {
+		optSubject = true
+	}
+
+	var searchTerm string
+	if optReply {
+		if optSubject || len(cmd.Args) == 1 {
+			return errors.New("Can't specify /REPLY and a search term or /SUBJECT")
+		}
+		msg, err := this.Q.ReadMessage(ctx, storage.ReadMessageParams{
+			Folder: this.Folder.Name,
+			ID:     this.MsgID,
+		})
+		if err != nil {
+			return err
+		}
+		searchTerm = "Re: " + msg.Subject
+	} else {
+		searchTerm = cmd.Args[0]
+	}
+
+	allMsgs := []storage.Message{}
+	msgs := []storage.Message{}
+	var start int64
+	for i := range optFolders {
+		switch optStart {
+		case -1:
+			start = 1
+		case 0:
+			start, err := this.Q.LastMsgidIgnoringSeen(ctx, optFolders[i])
+			if err != nil || start == 0 {
+				continue
+			}
+		default:
+			start = optStart
+		}
+		if optReply {
+			if optReverse {
+				msgs, err = this.Q.SearchReplyReverse(ctx,
+					storage.SearchReplyReverseParams{
+						Subject: searchTerm,
+						ID:      start,
+						Folder:  optFolders[i],
+					})
+			} else {
+				msgs, err = this.Q.SearchReply(ctx,
+					storage.SearchReplyParams{
+						Subject: searchTerm,
+						ID:      start,
+						Folder:  optFolders[i],
+					})
+			}
+		} else if optSubject {
+			if optReverse {
+				msgs, err = this.Q.SearchSubjectReverse(ctx,
+					storage.SearchSubjectReverseParams{
+						Column1: nullStr(searchTerm),
+						ID:      start,
+						Folder:  optFolders[i],
+					})
+			} else {
+				msgs, err = this.Q.SearchSubject(ctx,
+					storage.SearchSubjectParams{
+						Column1: nullStr(searchTerm),
+						ID:      start,
+						Folder:  optFolders[i],
+					})
+			}
+		} else {
+			if optReverse {
+				msgs, err = this.Q.SearchReverse(ctx,
+					storage.SearchReverseParams{
+						Column1: nullStr(searchTerm),
+						ID:      start,
+						Folder:  optFolders[i],
+					})
+			} else {
+				msgs, err = this.Q.Search(ctx,
+					storage.SearchParams{
+						Column1: nullStr(searchTerm),
+						ID:      start,
+						Folder:  optFolders[i],
+					})
+			}
+		}
+		if err != nil {
+			continue
+		}
+		if len(allMsgs)+len(msgs) > 100 {
+			fmt.Println("Too many messages match; narrow search term.")
+			return nil
+		}
+		allMsgs = append(allMsgs, msgs...)
+	}
+
+	if len(allMsgs) == 0 {
+		fmt.Println("No messages found.")
+		return nil
+	}
+	buf := strings.Builder{}
+	for _, msg := range allMsgs {
+		buf.WriteString(msg.String())
+		buf.WriteString("\n\n")
+	}
+	pager.Pager(buf.String())
+
+	return nil
+}
diff --git a/repl/storage.go b/repl/storage.go
new file mode 100644
index 0000000000000000000000000000000000000000..10d8378bed27646b94a8778853391595b2c81bf8
--- /dev/null
+++ b/repl/storage.go
@@ -0,0 +1,10 @@
+package repl
+
+import "database/sql"
+
+func nullStr(s string) sql.NullString {
+	return sql.NullString{
+		String: s,
+		Valid:  true,
+	}
+}
diff --git a/storage/messages.sql.go b/storage/messages.sql.go
index d804c873c029be4111beea24076c144d5af24a42..5a5fb40c24c50ea3bad2d6b292a3afe920827895 100644
--- a/storage/messages.sql.go
+++ b/storage/messages.sql.go
@@ -7,6 +7,7 @@ package storage
 
 import (
 	"context"
+	"database/sql"
 	"time"
 )
 
@@ -52,9 +53,9 @@ func (q *Queries) DeleteAllMessages(ctx context.Context, folder string) error {
 
 const getLastRead = `-- name: GetLastRead :many
 SELECT CAST(MAX(m.id) AS INT) AS id, m.author FROM messages AS m, users AS u
-WHERE folder = ? AND u.login == m.author
-GROUP BY m.author
-ORDER BY m.author
+  WHERE folder = ? AND u.login == m.author
+  GROUP BY m.author
+  ORDER BY m.author
 `
 
 type GetLastReadRow struct {
@@ -88,9 +89,9 @@ func (q *Queries) GetLastRead(ctx context.Context, folder string) ([]GetLastRead
 
 const getLastReadByEnabled = `-- name: GetLastReadByEnabled :many
 SELECT CAST(MAX(m.id) AS INT) AS id, m.author FROM messages AS m, users AS u
-WHERE folder = ? AND u.login == m.author AND u.disabled = ?
-GROUP BY m.author
-ORDER BY m.author
+  WHERE folder = ? AND u.login == m.author AND u.disabled = ?
+  GROUP BY m.author
+  ORDER BY m.author
 `
 
 type GetLastReadByEnabledParams struct {
@@ -128,7 +129,7 @@ func (q *Queries) GetLastReadByEnabled(ctx context.Context, arg GetLastReadByEna
 
 const getLastReadByUser = `-- name: GetLastReadByUser :one
 SELECT CAST(MAX(m.id) AS INT) AS id, m.author FROM messages AS m, users AS u
-WHERE folder = ? AND u.login == m.author AND m.author = ?
+  WHERE folder = ? AND u.login == m.author AND m.author = ?
 `
 
 type GetLastReadByUserParams struct {
@@ -258,6 +259,293 @@ func (q *Queries) ReadMessage(ctx context.Context, arg ReadMessageParams) (Messa
 	return i, err
 }
 
+const search = `-- name: Search :many
+SELECT id, folder, author, subject, message, permanent, shutdown, expiration, create_at, update_at FROM messages
+  WHERE (message LIKE '%' || ?1 || '%'
+     OR subject LIKE '%' || ?1 || '%')
+    AND id >= ?2
+    AND folder = ?3
+`
+
+type SearchParams struct {
+	Column1 sql.NullString
+	ID      int64
+	Folder  string
+}
+
+func (q *Queries) Search(ctx context.Context, arg SearchParams) ([]Message, error) {
+	rows, err := q.db.QueryContext(ctx, search, arg.Column1, arg.ID, arg.Folder)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var items []Message
+	for rows.Next() {
+		var i Message
+		if err := rows.Scan(
+			&i.ID,
+			&i.Folder,
+			&i.Author,
+			&i.Subject,
+			&i.Message,
+			&i.Permanent,
+			&i.Shutdown,
+			&i.Expiration,
+			&i.CreateAt,
+			&i.UpdateAt,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const searchReply = `-- name: SearchReply :many
+SELECT id, folder, author, subject, message, permanent, shutdown, expiration, create_at, update_at FROM messages
+  WHERE subject = ?
+    AND id >= ?
+    AND folder = ?
+`
+
+type SearchReplyParams struct {
+	Subject string
+	ID      int64
+	Folder  string
+}
+
+func (q *Queries) SearchReply(ctx context.Context, arg SearchReplyParams) ([]Message, error) {
+	rows, err := q.db.QueryContext(ctx, searchReply, arg.Subject, arg.ID, arg.Folder)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var items []Message
+	for rows.Next() {
+		var i Message
+		if err := rows.Scan(
+			&i.ID,
+			&i.Folder,
+			&i.Author,
+			&i.Subject,
+			&i.Message,
+			&i.Permanent,
+			&i.Shutdown,
+			&i.Expiration,
+			&i.CreateAt,
+			&i.UpdateAt,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const searchReplyReverse = `-- name: SearchReplyReverse :many
+SELECT id, folder, author, subject, message, permanent, shutdown, expiration, create_at, update_at FROM messages
+  WHERE subject = ?
+    AND id <= ?
+    AND folder = ?
+  ORDER BY DESC
+`
+
+type SearchReplyReverseParams struct {
+	Subject string
+	ID      int64
+	Folder  string
+}
+
+func (q *Queries) SearchReplyReverse(ctx context.Context, arg SearchReplyReverseParams) ([]Message, error) {
+	rows, err := q.db.QueryContext(ctx, searchReplyReverse, arg.Subject, arg.ID, arg.Folder)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var items []Message
+	for rows.Next() {
+		var i Message
+		if err := rows.Scan(
+			&i.ID,
+			&i.Folder,
+			&i.Author,
+			&i.Subject,
+			&i.Message,
+			&i.Permanent,
+			&i.Shutdown,
+			&i.Expiration,
+			&i.CreateAt,
+			&i.UpdateAt,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const searchReverse = `-- name: SearchReverse :many
+SELECT id, folder, author, subject, message, permanent, shutdown, expiration, create_at, update_at FROM messages
+  WHERE (message LIKE '%' || ?1 || '%'
+     OR subject LIKE '%' || ?1 || '%')
+    AND id <= ?2
+    AND folder = ?3
+  ORDER BY DESC
+`
+
+type SearchReverseParams struct {
+	Column1 sql.NullString
+	ID      int64
+	Folder  string
+}
+
+func (q *Queries) SearchReverse(ctx context.Context, arg SearchReverseParams) ([]Message, error) {
+	rows, err := q.db.QueryContext(ctx, searchReverse, arg.Column1, arg.ID, arg.Folder)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var items []Message
+	for rows.Next() {
+		var i Message
+		if err := rows.Scan(
+			&i.ID,
+			&i.Folder,
+			&i.Author,
+			&i.Subject,
+			&i.Message,
+			&i.Permanent,
+			&i.Shutdown,
+			&i.Expiration,
+			&i.CreateAt,
+			&i.UpdateAt,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const searchSubject = `-- name: SearchSubject :many
+SELECT id, folder, author, subject, message, permanent, shutdown, expiration, create_at, update_at FROM messages
+  WHERE subject LIKE '%' || ? || '%'
+    AND id >= ?
+    AND folder = ?
+`
+
+type SearchSubjectParams struct {
+	Column1 sql.NullString
+	ID      int64
+	Folder  string
+}
+
+func (q *Queries) SearchSubject(ctx context.Context, arg SearchSubjectParams) ([]Message, error) {
+	rows, err := q.db.QueryContext(ctx, searchSubject, arg.Column1, arg.ID, arg.Folder)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var items []Message
+	for rows.Next() {
+		var i Message
+		if err := rows.Scan(
+			&i.ID,
+			&i.Folder,
+			&i.Author,
+			&i.Subject,
+			&i.Message,
+			&i.Permanent,
+			&i.Shutdown,
+			&i.Expiration,
+			&i.CreateAt,
+			&i.UpdateAt,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const searchSubjectReverse = `-- name: SearchSubjectReverse :many
+SELECT id, folder, author, subject, message, permanent, shutdown, expiration, create_at, update_at FROM messages
+  WHERE subject LIKE '%' || ? || '%'
+    AND id <= ?
+    AND folder = ?
+  ORDER BY DESC
+`
+
+type SearchSubjectReverseParams struct {
+	Column1 sql.NullString
+	ID      int64
+	Folder  string
+}
+
+func (q *Queries) SearchSubjectReverse(ctx context.Context, arg SearchSubjectReverseParams) ([]Message, error) {
+	rows, err := q.db.QueryContext(ctx, searchSubjectReverse, arg.Column1, arg.ID, arg.Folder)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var items []Message
+	for rows.Next() {
+		var i Message
+		if err := rows.Scan(
+			&i.ID,
+			&i.Folder,
+			&i.Author,
+			&i.Subject,
+			&i.Message,
+			&i.Permanent,
+			&i.Shutdown,
+			&i.Expiration,
+			&i.CreateAt,
+			&i.UpdateAt,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
 const setMessageSeen = `-- name: SetMessageSeen :exec
 INSERT INTO seen (login, folder, msgid) VALUES (?, ?, ?)
 `
diff --git a/storage/migrations/1_create_table.up.sql b/storage/migrations/1_create_table.up.sql
index 46dfee3deadfdb098d29d47f3c288e045e1d5d33..acec6f82f018420ea63ef35cbb93fce6db95f451 100644
--- a/storage/migrations/1_create_table.up.sql
+++ b/storage/migrations/1_create_table.up.sql
@@ -3,7 +3,7 @@ CREATE TABLE users (
   name        VARCHAR(53)  NOT NULL,
   admin       INT          DEFAULT 0 NOT NULL,
   moderator   INT          DEFAULT 0 NOT NULL,
-  alert       INT          NOT NULL DEFAULT 0,  --- 0=no, 1=brief, 2=readnew
+  alert       INT          DEFAULT 0 NOT NULL,  --- 0=no, 1=brief, 2=readnew
   disabled    INT          DEFAULT 0 NOT NULL,
   last_login  TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
   create_at   TIMESTAMP    DEFAULT CURRENT_TIMESTAMP NOT NULL,
diff --git a/storage/queries/messages.sql b/storage/queries/messages.sql
index d6f48c654c0157869d4a1a7012b61d6eeed1cd1d..901d71c0e471aebc4f27552895b7e7a6b3df4dd6 100644
--- a/storage/queries/messages.sql
+++ b/storage/queries/messages.sql
@@ -41,16 +41,57 @@ SELECT CAST(MAX(id) AS INT) FROM messages AS m WHERE m.folder = ?1;
 --- TODO: These get the max message written, not read.  Leaving for now; easier to test.
 -- name: GetLastRead :many
 SELECT CAST(MAX(m.id) AS INT) AS id, m.author FROM messages AS m, users AS u
-WHERE folder = ? AND u.login == m.author
-GROUP BY m.author
-ORDER BY m.author;
+  WHERE folder = ? AND u.login == m.author
+  GROUP BY m.author
+  ORDER BY m.author;
 
 -- name: GetLastReadByEnabled :many
 SELECT CAST(MAX(m.id) AS INT) AS id, m.author FROM messages AS m, users AS u
-WHERE folder = ? AND u.login == m.author AND u.disabled = ?
-GROUP BY m.author
-ORDER BY m.author;
+  WHERE folder = ? AND u.login == m.author AND u.disabled = ?
+  GROUP BY m.author
+  ORDER BY m.author;
 
 -- name: GetLastReadByUser :one
 SELECT CAST(MAX(m.id) AS INT) AS id, m.author FROM messages AS m, users AS u
-WHERE folder = ? AND u.login == m.author AND m.author = ?;
+  WHERE folder = ? AND u.login == m.author AND m.author = ?;
+
+-- name: Search :many
+SELECT * FROM messages
+  WHERE (message LIKE '%' || ?1 || '%'
+     OR subject LIKE '%' || ?1 || '%')
+    AND id >= ?2
+    AND folder = ?3;
+
+-- name: SearchReverse :many
+SELECT * FROM messages
+  WHERE (message LIKE '%' || ?1 || '%'
+     OR subject LIKE '%' || ?1 || '%')
+    AND id <= ?2
+    AND folder = ?3
+  ORDER BY DESC;
+
+-- name: SearchSubject :many
+SELECT * FROM messages
+  WHERE subject LIKE '%' || ? || '%'
+    AND id >= ?
+    AND folder = ?;
+
+-- name: SearchSubjectReverse :many
+SELECT * FROM messages
+  WHERE subject LIKE '%' || ? || '%'
+    AND id <= ?
+    AND folder = ?
+  ORDER BY DESC;
+
+-- name: SearchReply :many
+SELECT * FROM messages
+  WHERE subject = ?
+    AND id >= ?
+    AND folder = ?;
+
+-- name: SearchReplyReverse :many
+SELECT * FROM messages
+  WHERE subject = ?
+    AND id <= ?
+    AND folder = ?
+  ORDER BY DESC;
diff --git a/storage/queries/standard.sql b/storage/queries/standard.sql
index cff89bb0f1d82c98ae4510513db2397f47a73b04..bd37e86e8fef6674b656494ed03027088f59e6ea 100644
--- a/storage/queries/standard.sql
+++ b/storage/queries/standard.sql
@@ -37,6 +37,9 @@ INSERT INTO messages (id, folder) VALUES (?, ?);
 -- name: ListMessages :many
 SELECT * FROM messages WHERE folder = ? ORDER BY id;
 
+-- name: ListMessageIDs :many
+SELECT id FROM messages WHERE folder = ? ORDER BY id;
+
 -- name: GetMessage :one
 SELECT * FROM messages WHERE id = ? AND folder = ?;
 
diff --git a/storage/standard.sql.go b/storage/standard.sql.go
index 69f0f8f40daa617c9ac334bc6348fb51d197d827..f6c4615c285fbb883b1ae91d779314aa26cc9bb4 100644
--- a/storage/standard.sql.go
+++ b/storage/standard.sql.go
@@ -530,6 +530,33 @@ func (q *Queries) ListMark(ctx context.Context, arg ListMarkParams) ([]Mark, err
 	return items, nil
 }
 
+const listMessageIDs = `-- name: ListMessageIDs :many
+SELECT id FROM messages WHERE folder = ? ORDER BY id
+`
+
+func (q *Queries) ListMessageIDs(ctx context.Context, folder string) ([]int64, error) {
+	rows, err := q.db.QueryContext(ctx, listMessageIDs, folder)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var items []int64
+	for rows.Next() {
+		var id int64
+		if err := rows.Scan(&id); err != nil {
+			return nil, err
+		}
+		items = append(items, id)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
 const listMessages = `-- name: ListMessages :many
 SELECT id, folder, author, subject, message, permanent, shutdown, expiration, create_at, update_at FROM messages WHERE folder = ? ORDER BY id
 `