urit/internal/menu/mail.go
2026-05-02 21:11:50 -04:00

565 lines
14 KiB
Go

// mail.go implements the private mail subsystem.
//
// This replaces PCOM.C from the original TAG-BBS. The original stored
// mail in a Mail.Keys file (array of Mail_Data structs with slot numbers,
// author/recipient codes) and a Mail.Data file (fixed 2500-byte bodies
// at lseek offsets). Our version stores everything in the SQLite mail table.
//
// The user-facing flow is preserved:
// PCom() → Mail_Prompt: L>ist, R>ead, S>end, Q>uit (+ sysop: $>Read, D>elete)
//
// Key improvements over the original:
// - Read tracking (unread flag) — the original had no concept of read/unread
// - Search by name uses the store's case-insensitive lookup instead of
// scanning all slots with the jive() pattern matcher
// - No slot-count limit — mail limited by disk space, not a fixed array
package menu
import (
"fmt"
"strings"
"github.com/urit/urit/internal/models"
"github.com/urit/urit/internal/session"
)
// cmdMail is the entry point for private mail.
// Replaces PCom() → Mail_Prompt() from PCOM.C.
func cmdMail(ctx *Context) error {
ctx.Sess.NewLine()
// Show unread count
unread, _ := ctx.Store.CountUnreadMail(ctx.User.ID)
if unread > 0 {
ctx.Sess.Color(session.AnsiFgBrightYellow)
if unread == 1 {
ctx.Sess.WriteString(" You have 1 letter waiting!\r\n")
} else {
ctx.Sess.Printf(" You have %d letters waiting!\r\n", unread)
}
ctx.Sess.Color(session.AnsiReset)
}
for {
ctx.Sess.CheckTime()
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("L>ist R>ead S>end Q>uit\r\n")
if ctx.User.IsSysop() {
ctx.Sess.WriteString("Sysop: $>Read-all D>elete\r\n")
}
ctx.Sess.WriteString("Mail> ")
ctx.Sess.Color(session.AnsiReset)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return nil
}
switch toUpper(ch) {
case 'L':
ctx.Sess.WriteString("List\r\n")
mailList(ctx)
case 'R':
ctx.Sess.WriteString("Read\r\n")
mailRead(ctx)
case 'S':
ctx.Sess.WriteString("Send\r\n")
mailWrite(ctx, 0, "")
case '$', '4':
// Sysop: read all mail (any user's)
if !ctx.User.IsSysop() {
continue
}
ctx.Sess.WriteString("Read All\r\n")
mailSysopRead(ctx)
case 'D':
if !ctx.User.IsSysop() {
continue
}
ctx.Sess.WriteString("Delete\r\n")
mailSysopDelete(ctx)
case 'Q', '\x1b':
ctx.Sess.WriteString("Quit\r\n")
return nil
case '?', '/':
ctx.Sess.WriteString("Help\r\n")
ctx.Sess.WriteString(" L - List your mail\r\n")
ctx.Sess.WriteString(" R - Read your mail\r\n")
ctx.Sess.WriteString(" S - Send a letter\r\n")
ctx.Sess.WriteString(" Q - Return to main menu\r\n")
if ctx.User.IsSysop() {
ctx.Sess.WriteString(" $ - Read all mail (sysop)\r\n")
ctx.Sess.WriteString(" D - Delete mail (sysop)\r\n")
}
}
}
}
// mailList shows all mail addressed to the current user.
// Replaces Mail_List(mh, User.Slot_Number) from PCOM.C.
func mailList(ctx *Context) {
letters, err := ctx.Store.ListMailFor(ctx.User.ID)
if err != nil {
ctx.Sess.WriteString(" Error loading mail.\r\n")
return
}
if len(letters) == 0 {
ctx.Sess.WriteString(" No mail.\r\n")
return
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
ctx.Sess.WriteString("Your Mail\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 55) + "\r\n")
for _, m := range letters {
unreadMark := " "
if !m.Read {
unreadMark = "*"
}
ctx.Sess.Printf(" %s[%3d] %-25s from %s %s\r\n",
unreadMark, m.ID, truncate(m.Title, 25), m.Author,
m.CreatedAt.Format("Jan 02"))
}
ctx.Sess.NewLine()
}
// mailRead reads the user's mail sequentially with between-letter prompts.
// Replaces Mail_Immediate() and Mail_Read() flow from PCOM.C.
func mailRead(ctx *Context) {
letters, err := ctx.Store.ListMailFor(ctx.User.ID)
if err != nil {
ctx.Sess.WriteString(" Error loading mail.\r\n")
return
}
if len(letters) == 0 {
ctx.Sess.WriteString(" No mail.\r\n")
return
}
ctx.Sess.Printf(" %d letter(s). Read which #? (Enter for all, 0 to cancel): ", len(letters))
numStr, err := ctx.Sess.ReadLine("", 6, inputTimeout)
if err != nil {
return
}
num := parseIntDefault(strings.TrimSpace(numStr), -1)
if num == 0 {
return
}
if num > 0 {
// Read a specific letter by ID
m, err := ctx.Store.GetMail(int64(num))
if err != nil || m == nil || m.ToID != ctx.User.ID {
ctx.Sess.WriteString(" Letter not found.\r\n")
return
}
mailDisplay(ctx, m)
mailMarkRead(ctx, m)
mailBetweenPrompt(ctx, m)
return
}
// Read all — sequential
idx := 0
for idx < len(letters) {
m := letters[idx]
mailDisplay(ctx, m)
mailMarkRead(ctx, m)
action := mailBetweenPrompt(ctx, m)
switch action {
case mailNext:
idx++
case mailAgain:
// stay
case mailQuit:
return
case mailDeleted:
// Refresh list
letters, err = ctx.Store.ListMailFor(ctx.User.ID)
if err != nil || idx >= len(letters) {
return
}
}
}
ctx.Sess.WriteString(" End of mail.\r\n")
}
// mailDisplay renders a single letter.
// Replaces Send_Mail() from PCOM.C.
func mailDisplay(ctx *Context, m *models.Mail) {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.Printf("Letter #%d\r\n", m.ID)
ctx.Sess.Color(session.AnsiReset)
if m.Title != "" {
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.Printf("Subject: %s\r\n", m.Title)
ctx.Sess.Color(session.AnsiReset)
}
ctx.Sess.Printf(" From: %s [%d]\r\n", m.Author, m.FromID)
ctx.Sess.Printf(" To: %s [%d]\r\n", m.Recipient, m.ToID)
ctx.Sess.Printf(" Time: %s\r\n", m.CreatedAt.Format("Mon Jan 02 15:04:05 2006"))
ctx.Sess.NewLine()
ctx.Sess.Paginate(m.Body, idleTimeout)
}
// mailMarkRead marks a letter as read if it hasn't been.
func mailMarkRead(ctx *Context, m *models.Mail) {
if !m.Read {
ctx.Store.MarkMailRead(m.ID)
m.Read = true
}
}
type mailAction int
const (
mailNext mailAction = iota
mailAgain
mailQuit
mailDeleted
)
// mailBetweenPrompt shows navigation options between letters.
// Replaces Between_Mail_Prompt() from PCOM.C.
func mailBetweenPrompt(ctx *Context, m *models.Mail) mailAction {
for {
ctx.Sess.CheckTime()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("\r\nN>ext A>gain R>eply D>elete Q>uit\r\n")
ctx.Sess.WriteString("Mail> ")
ctx.Sess.Color(session.AnsiReset)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return mailQuit
}
switch toUpper(ch) {
case 'N', 'C', '\r':
ctx.Sess.WriteString("Next\r\n")
return mailNext
case 'A':
ctx.Sess.WriteString("Again\r\n")
return mailAgain
case 'R':
ctx.Sess.WriteString("Reply\r\n")
// Reply goes to the sender
mailWrite(ctx, m.FromID, "Re: "+m.Title)
return mailNext
case 'D':
ctx.Sess.WriteString("Delete\r\n")
yes, err := ctx.Sess.Confirm(" Delete this letter? ", inputTimeout)
if err != nil {
return mailQuit
}
if yes {
if err := ctx.Store.DeleteMail(m.ID); err != nil {
ctx.Sess.WriteString(" Error deleting.\r\n")
} else {
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.WriteString(" Deleted.\r\n")
ctx.Sess.Color(session.AnsiReset)
return mailDeleted
}
}
case 'Q':
ctx.Sess.WriteString("Quit\r\n")
return mailQuit
case '?', '/':
ctx.Sess.WriteString("Help\r\n")
ctx.Sess.WriteString(" N/Enter - Next letter\r\n")
ctx.Sess.WriteString(" A - Read again\r\n")
ctx.Sess.WriteString(" R - Reply to sender\r\n")
ctx.Sess.WriteString(" D - Delete this letter\r\n")
ctx.Sess.WriteString(" Q - Return to mail menu\r\n")
}
}
}
// mailWrite composes and sends a new letter.
// Replaces Mail_Write() and Mail_Reply_To() from PCOM.C.
//
// If toUserID > 0, it's a direct reply (skip recipient selection).
// If defaultTitle is set, it's pre-filled as the subject.
func mailWrite(ctx *Context, toUserID int64, defaultTitle string) {
ctx.Sess.NewLine()
// Determine recipient
var recipient *models.User
if toUserID > 0 {
// Direct reply — recipient already known
var err error
recipient, err = ctx.Store.GetUser(toUserID)
if err != nil || recipient == nil {
ctx.Sess.WriteString(" Recipient account not found.\r\n")
return
}
ctx.Sess.Printf(" Writing to %s [%d]\r\n\r\n", recipient.Name, recipient.ID)
} else {
// Ask for recipient by name
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Send to (name or Q to quit): ")
ctx.Sess.Color(session.AnsiReset)
nameInput, err := ctx.Sess.ReadLine("", 30, inputTimeout)
if err != nil {
return
}
nameInput = strings.TrimSpace(nameInput)
if nameInput == "" || strings.EqualFold(nameInput, "Q") {
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
recipient, err = ctx.Store.GetUserByName(nameInput)
if err != nil || recipient == nil {
ctx.Sess.WriteString(" No user with that name.\r\n")
return
}
ctx.Sess.Printf(" Writing to %s [%d]\r\n\r\n", recipient.Name, recipient.ID)
}
// Subject
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Subject (Q to quit): ")
ctx.Sess.Color(session.AnsiReset)
title, err := ctx.Sess.ReadLine(defaultTitle, 60, inputTimeout)
if err != nil {
return
}
title = strings.TrimSpace(title)
if strings.EqualFold(title, "Q") || title == "" {
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
// Body
ctx.Sess.WriteString("\r\nEnter your letter (blank line when done):\r\n")
lines := mailEditLoop(ctx)
if lines == nil {
return
}
// Edit menu — simplified from message boards (no continue/edit)
for {
ctx.Sess.WriteString("\r\nA>bort S>end L>ist C>ontinue\r\n")
ctx.Sess.WriteString("Letter> ")
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return
}
switch toUpper(ch) {
case 'S':
ctx.Sess.WriteString("Send\r\n")
goto send
case 'A', 'Q':
ctx.Sess.WriteString("Abort\r\n")
yes, err := ctx.Sess.Confirm(" Discard this letter? ", inputTimeout)
if err != nil || yes {
ctx.Sess.WriteString(" Discarded.\r\n")
return
}
case 'C':
ctx.Sess.WriteString("Continue\r\n")
more := mailEditLoop(ctx)
if more == nil {
return
}
lines = append(lines, more...)
case 'L':
ctx.Sess.WriteString("List\r\n\r\n")
for i, l := range lines {
ctx.Sess.Printf("%3d> %s\r\n", i+1, l)
}
}
}
send:
body := strings.Join(lines, "\n")
if strings.TrimSpace(body) == "" {
ctx.Sess.WriteString(" Empty letter — not sent.\r\n")
return
}
mail := &models.Mail{
Title: title,
Author: ctx.User.Name,
FromID: ctx.User.ID,
ToID: recipient.ID,
Recipient: recipient.Name,
Body: body,
}
if err := ctx.Store.CreateMail(mail); err != nil {
ctx.Sess.WriteString(" Error sending letter.\r\n")
return
}
// Update stats
ctx.User.MailSent++
ctx.Store.UpdateUser(ctx.User)
ctx.Store.IncrementStat("mail_sent", 1)
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.Printf(" Letter sent to %s.\r\n", recipient.Name)
ctx.Sess.Color(session.AnsiReset)
}
// mailEditLoop reads lines until a blank line, same as message editor.
func mailEditLoop(ctx *Context) []string {
var lines []string
lineNum := 1
for {
ctx.Sess.Printf("%3d> ", lineNum)
line, err := ctx.Sess.ReadLine("", 75, inputTimeout)
if err != nil {
return nil
}
if strings.TrimSpace(line) == "" {
break
}
lines = append(lines, line)
lineNum++
if lineNum > 100 {
ctx.Sess.WriteString(" Maximum lines reached.\r\n")
break
}
}
return lines
}
// mailSysopRead lets the sysop read any user's mail.
// Replaces the '$' command (Mail_Read_Frontend) from PCOM.C.
func mailSysopRead(ctx *Context) {
ctx.Sess.WriteString(" Enter user name to read mail for: ")
nameInput, err := ctx.Sess.ReadLine("", 30, inputTimeout)
if err != nil {
return
}
nameInput = strings.TrimSpace(nameInput)
if nameInput == "" {
return
}
target, err := ctx.Store.GetUserByName(nameInput)
if err != nil || target == nil {
ctx.Sess.WriteString(" No user with that name.\r\n")
return
}
letters, err := ctx.Store.ListMailFor(target.ID)
if err != nil {
ctx.Sess.WriteString(" Error loading mail.\r\n")
return
}
if len(letters) == 0 {
ctx.Sess.Printf(" No mail for %s.\r\n", target.Name)
return
}
ctx.Sess.Printf(" %d letter(s) for %s:\r\n\r\n", len(letters), target.Name)
for _, m := range letters {
unreadMark := " "
if !m.Read {
unreadMark = "*"
}
ctx.Sess.Printf(" %s[%3d] %-25s from %-12s to %-12s %s\r\n",
unreadMark, m.ID, truncate(m.Title, 25),
m.Author, m.Recipient, m.CreatedAt.Format("Jan 02"))
}
ctx.Sess.WriteString("\r\n Read which # (0 to cancel): ")
numStr, err := ctx.Sess.ReadLine("", 6, inputTimeout)
if err != nil {
return
}
num := parseIntDefault(numStr, 0)
if num == 0 {
return
}
m, err := ctx.Store.GetMail(int64(num))
if err != nil || m == nil {
ctx.Sess.WriteString(" Letter not found.\r\n")
return
}
mailDisplay(ctx, m)
}
// mailSysopDelete lets the sysop delete any mail.
// Replaces Mail_Delete_Frontend() from PCOM.C.
func mailSysopDelete(ctx *Context) {
ctx.Sess.WriteString(" Delete letter #: ")
numStr, err := ctx.Sess.ReadLine("", 6, inputTimeout)
if err != nil {
return
}
num := parseIntDefault(numStr, 0)
if num == 0 {
return
}
m, err := ctx.Store.GetMail(int64(num))
if err != nil || m == nil {
ctx.Sess.WriteString(" Letter not found.\r\n")
return
}
ctx.Sess.Printf(" Letter #%d: \"%s\" from %s to %s\r\n",
m.ID, m.Title, m.Author, m.Recipient)
yes, err := ctx.Sess.Confirm(" Delete? ", inputTimeout)
if err != nil || !yes {
ctx.Sess.WriteString(" Not deleted.\r\n")
return
}
if err := ctx.Store.DeleteMail(m.ID); err != nil {
ctx.Sess.WriteString(" Error deleting.\r\n")
return
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.WriteString(" Deleted.\r\n")
ctx.Sess.Color(session.AnsiReset)
}
// truncate shortens a string to maxLen, adding "..." if truncated.
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return fmt.Sprintf("%-*s", maxLen, s)
}
return s[:maxLen-3] + "..."
}