// 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] + "..." }