565 lines
14 KiB
Go
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] + "..."
|
|
}
|