1package tui
2
3import (
4 "strconv"
5 "strings"
6
7 "charm.land/bubbles/v2/textinput"
8 tea "charm.land/bubbletea/v2"
9 "charm.land/lipgloss/v2"
10 "github.com/floatpane/matcha/theme"
11)
12
13// Supported account protocols.
14const (
15 protocolIMAP = "imap"
16 protocolJMAP = "jmap"
17 protocolPOP3 = "pop3"
18 protocolMaildir = "maildir"
19)
20
21// loginProtocols are the selectable protocols shown in the protocol combobox,
22// in cycle order.
23var loginProtocols = []string{protocolIMAP, protocolJMAP, protocolPOP3, protocolMaildir}
24
25// Login holds the state for the login/add account form.
26type Login struct {
27 focusIndex int
28 inputs []textinput.Model
29 showCustom bool // Show custom server fields
30 useOAuth2 bool // Use OAuth2 instead of password (for gmail)
31 isEditMode bool // Whether we're editing an existing account
32 accountID string
33 hideTips bool
34 width int
35 height int
36}
37
38const (
39 inputProtocol = iota // "imap", "jmap", or "pop3"
40 inputProvider // "gmail", "icloud", or "custom"
41 inputName
42 inputEmail
43 inputFetchEmail
44 inputSendAsEmail
45 inputAuthMethod // "password" or "oauth2" (shown for gmail)
46 inputPassword
47 inputIMAPServer
48 inputIMAPPort
49 inputSMTPServer
50 inputSMTPPort
51 inputInsecure
52 inputCatchAll // "true/false" — show all inbox messages regardless of To address
53 inputJMAPEndpoint // JMAP session URL
54 inputPOP3Server
55 inputPOP3Port
56 inputMaildirPath // Local Maildir root path
57 inputCount
58)
59
60// NewLogin creates a new login model for adding accounts.
61func NewLogin(hideTips bool) *Login {
62 m := &Login{
63 inputs: make([]textinput.Model, inputCount),
64 hideTips: hideTips,
65 }
66
67 tiStyles := ThemedTextInputStyles()
68 var t textinput.Model
69 for i := range m.inputs {
70 t = textinput.New()
71 t.CharLimit = 128
72 t.SetStyles(tiStyles)
73
74 switch i {
75 case inputProtocol:
76 // Rendered as a combobox (see viewProtocolCombobox); the textinput
77 // is only used to hold the selected value. Seed a default so a
78 // protocol is always selected.
79 t.SetValue(loginProtocols[0])
80 t.Prompt = "🌐 > "
81 case inputProvider:
82 t.Placeholder = "Provider (gmail, outlook, icloud, or custom)"
83 t.Prompt = "🏢 > "
84 case inputName:
85 t.Placeholder = "Display Name"
86 t.Prompt = "👤 > "
87 case inputEmail:
88 t.Placeholder = "Username"
89 t.Prompt = "🏠 > "
90 case inputFetchEmail:
91 t.Placeholder = "Email Address (comma-separated for multiple)"
92 t.Prompt = "📧 > "
93 case inputSendAsEmail:
94 t.Placeholder = "Send As Email (optional From header override)"
95 t.Prompt = "✉️ > "
96 case inputAuthMethod:
97 t.Placeholder = "Auth Method (password, oauth2, or token)"
98 t.Prompt = "🔐 > "
99 case inputPassword:
100 t.Placeholder = "Password / App Password"
101 t.EchoMode = textinput.EchoPassword
102 t.Prompt = "🔑 > "
103 case inputIMAPServer:
104 t.Placeholder = "IMAP Server (e.g., imap.example.com)"
105 t.Prompt = "📥 > "
106 case inputIMAPPort:
107 t.Placeholder = "IMAP Port (default: 993)"
108 t.Prompt = "🔢 > "
109 case inputSMTPServer:
110 t.Placeholder = "SMTP Server (e.g., smtp.example.com)"
111 t.Prompt = "📤 > "
112 case inputSMTPPort:
113 t.Placeholder = "SMTP Port (default: 587)"
114 t.Prompt = "🔢 > "
115 case inputInsecure:
116 t.Placeholder = "Insecure (true/false) - Skip TLS verification"
117 t.Prompt = "🔓 > "
118 case inputCatchAll:
119 t.Placeholder = "Catch-All (true/false) - Show all inbox messages"
120 t.Prompt = "📬 > "
121 case inputJMAPEndpoint:
122 t.Placeholder = "JMAP Session URL (e.g., https://api.fastmail.com/jmap/session)"
123 t.Prompt = "🔗 > "
124 case inputPOP3Server:
125 t.Placeholder = "POP3 Server (e.g., pop.example.com)"
126 t.Prompt = "📥 > "
127 case inputPOP3Port:
128 t.Placeholder = "POP3 Port (default: 995)"
129 t.Prompt = "🔢 > "
130 case inputMaildirPath:
131 t.Placeholder = "Maildir Path (e.g., ~/Mail or /var/mail/user)"
132 t.Prompt = "📁 > "
133 }
134 m.inputs[i] = t
135 }
136
137 return m
138}
139
140// Init initializes the login model.
141func (m *Login) Init() tea.Cmd {
142 return textinput.Blink
143}
144
145// protocol returns the currently selected protocol (defaults to "imap").
146func (m *Login) protocol() string {
147 p := m.inputs[inputProtocol].Value()
148 if p == "" {
149 return protocolIMAP
150 }
151 return p
152}
153
154// cycleProtocol moves the protocol selection by delta (+1 next, -1 previous),
155// wrapping around the loginProtocols list.
156func (m *Login) cycleProtocol(delta int) {
157 cur := m.protocol()
158 idx := 0
159 for i, p := range loginProtocols {
160 if p == cur {
161 idx = i
162 break
163 }
164 }
165 idx = (idx + delta + len(loginProtocols)) % len(loginProtocols)
166 m.inputs[inputProtocol].SetValue(loginProtocols[idx])
167}
168
169// visibleFields returns the ordered list of input indices the user should see
170// for the current protocol/provider/auth combination.
171func (m *Login) visibleFields() []int {
172 proto := m.protocol()
173 provider := m.inputs[inputProvider].Value()
174 hasOAuth := provider == "gmail" || provider == "outlook"
175
176 fields := []int{inputProtocol}
177
178 switch proto {
179 case protocolJMAP:
180 // JMAP: no provider selector, just endpoint + common fields
181 fields = append(fields, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputCatchAll, inputAuthMethod, inputPassword, inputJMAPEndpoint)
182 case protocolPOP3:
183 // POP3: custom server fields + SMTP for sending
184 fields = append(fields, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputCatchAll, inputPassword,
185 inputPOP3Server, inputPOP3Port, inputSMTPServer, inputSMTPPort, inputInsecure)
186 case protocolMaildir:
187 // Maildir: local filesystem only — no auth, no network.
188 fields = append(fields, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputCatchAll, inputMaildirPath)
189 default:
190 // IMAP (default): existing flow
191 fields = append(fields, inputProvider, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputCatchAll)
192 if hasOAuth {
193 fields = append(fields, inputAuthMethod)
194 }
195 if !m.useOAuth2 {
196 fields = append(fields, inputPassword)
197 }
198 if m.showCustom {
199 fields = append(fields, inputIMAPServer, inputIMAPPort, inputSMTPServer, inputSMTPPort, inputInsecure)
200 }
201 }
202
203 return fields
204}
205
206// Update handles messages for the login model.
207func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
208 switch msg := msg.(type) {
209 case tea.WindowSizeMsg:
210 m.width = msg.Width
211 m.height = msg.Height
212 for i := range m.inputs {
213 m.inputs[i].SetWidth(msg.Width - 6)
214 }
215
216 case tea.KeyPressMsg:
217 switch msg.String() {
218 case "esc":
219 return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
220
221 case keyLeft, keyRight, "h", "l", "space":
222 // On the protocol combobox, left/right (or h/l, space) cycle the
223 // selection instead of editing text. Elsewhere, fall through so the
224 // focused textinput handles the key normally.
225 if m.focusIndex == inputProtocol {
226 if msg.String() == keyLeft || msg.String() == "h" {
227 m.cycleProtocol(-1)
228 } else {
229 m.cycleProtocol(1)
230 }
231 return m, nil
232 }
233
234 case "ctrl+v":
235 // Toggle password visibility while focused on the password field,
236 // so typos in app-passwords are catchable without retyping.
237 if m.focusIndex == inputPassword {
238 if m.inputs[inputPassword].EchoMode == textinput.EchoPassword {
239 m.inputs[inputPassword].EchoMode = textinput.EchoNormal
240 } else {
241 m.inputs[inputPassword].EchoMode = textinput.EchoPassword
242 }
243 return m, nil
244 }
245
246 case keyEnter:
247 m.updateFlags()
248 visible := m.visibleFields()
249 lastField := visible[len(visible)-1]
250
251 if m.focusIndex == lastField {
252 return m, m.submitForm()
253 }
254 fallthrough
255
256 case "tab", keyShiftTab, "up", keyDown:
257 s := msg.String()
258 m.updateFlags()
259 visible := m.visibleFields()
260
261 // Find current position in visible fields
262 curPos := 0
263 for i, f := range visible {
264 if f == m.focusIndex {
265 curPos = i
266 break
267 }
268 }
269
270 if s == "up" || s == keyShiftTab {
271 curPos--
272 } else {
273 curPos++
274 }
275
276 if curPos >= len(visible) {
277 curPos = 0
278 } else if curPos < 0 {
279 curPos = len(visible) - 1
280 }
281
282 m.focusIndex = visible[curPos]
283
284 cmds := make([]tea.Cmd, len(m.inputs))
285 for i := 0; i < len(m.inputs); i++ {
286 if i == m.focusIndex {
287 cmds[i] = m.inputs[i].Focus()
288 } else {
289 m.inputs[i].Blur()
290 }
291 }
292 return m, tea.Batch(cmds...)
293 }
294 }
295
296 // Update the focused input field. The protocol field is a combobox, not a
297 // text field, so it never consumes raw input here.
298 var cmds = make([]tea.Cmd, len(m.inputs))
299 for i := range m.inputs {
300 if i == inputProtocol {
301 continue
302 }
303 m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
304 }
305
306 m.updateFlags()
307
308 return m, tea.Batch(cmds...)
309}
310
311// updateFlags recalculates showCustom and useOAuth2 from current inputs.
312func (m *Login) updateFlags() {
313 provider := m.inputs[inputProvider].Value()
314 m.showCustom = provider == "custom"
315 m.useOAuth2 = m.inputs[inputAuthMethod].Value() == "oauth2"
316
317 authMethod := m.inputs[inputAuthMethod].Value()
318 if m.protocol() == protocolJMAP && (authMethod == "token" || authMethod == "") {
319 m.inputs[inputPassword].Placeholder = "API Token"
320 } else {
321 m.inputs[inputPassword].Placeholder = "Password / App Password"
322 }
323}
324
325// validPort parses a port string and returns the integer value if it is within
326// the valid TCP/UDP port range (1-65535). Returns the fallback if the string is
327// empty or invalid.
328func validPort(s string, fallback int) int {
329 if s == "" {
330 return fallback
331 }
332 p, err := strconv.Atoi(s)
333 if err != nil || p < 1 || p > 65535 {
334 return fallback
335 }
336 return p
337}
338
339// submitForm builds and returns a Credentials message from the current inputs.
340func (m *Login) submitForm() func() tea.Msg {
341 imapPort := validPort(m.inputs[inputIMAPPort].Value(), 993)
342 smtpPort := validPort(m.inputs[inputSMTPPort].Value(), 587)
343 pop3Port := validPort(m.inputs[inputPOP3Port].Value(), 995)
344
345 proto := m.protocol()
346
347 var authMethod string
348 switch {
349 case proto == protocolJMAP:
350 authMethod = m.inputs[inputAuthMethod].Value()
351 if authMethod == "" {
352 authMethod = "token"
353 }
354 case m.useOAuth2:
355 authMethod = "oauth2"
356 default:
357 authMethod = "password"
358 }
359
360 insecure := m.inputs[inputInsecure].Value() == "true"
361 catchAll := m.inputs[inputCatchAll].Value() == "true"
362
363 return func() tea.Msg {
364 return Credentials{
365 Protocol: proto,
366 Provider: m.inputs[inputProvider].Value(),
367 Name: m.inputs[inputName].Value(),
368 Host: m.inputs[inputEmail].Value(),
369 FetchEmail: m.inputs[inputFetchEmail].Value(),
370 SendAsEmail: m.inputs[inputSendAsEmail].Value(),
371 CatchAll: catchAll,
372 Password: m.inputs[inputPassword].Value(),
373 IMAPServer: m.inputs[inputIMAPServer].Value(),
374 IMAPPort: imapPort,
375 SMTPServer: m.inputs[inputSMTPServer].Value(),
376 SMTPPort: smtpPort,
377 Insecure: insecure,
378 AuthMethod: authMethod,
379 JMAPEndpoint: m.inputs[inputJMAPEndpoint].Value(),
380 POP3Server: m.inputs[inputPOP3Server].Value(),
381 POP3Port: pop3Port,
382 MaildirPath: m.inputs[inputMaildirPath].Value(),
383 }
384 }
385}
386
387// viewProtocolCombobox renders the protocol selector as a segmented combobox,
388// highlighting the current selection and dimming the alternatives.
389func (m *Login) viewProtocolCombobox() string {
390 th := theme.ActiveTheme
391 focused := m.focusIndex == inputProtocol
392 cur := m.protocol()
393
394 promptColor := th.MutedText
395 if focused {
396 promptColor = th.AccentText
397 }
398 prompt := lipgloss.NewStyle().Foreground(promptColor).Render("🌐 > ")
399
400 selStyle := lipgloss.NewStyle().Foreground(th.Accent).Bold(true)
401 optStyle := lipgloss.NewStyle().Foreground(th.DimText)
402
403 parts := make([]string, len(loginProtocols))
404 for i, p := range loginProtocols {
405 switch {
406 case p == cur && focused:
407 parts[i] = selStyle.Render("‹ " + p + " ›")
408 case p == cur:
409 parts[i] = selStyle.Render(" " + p + " ")
410 default:
411 parts[i] = optStyle.Render(" " + p + " ")
412 }
413 }
414
415 return prompt + strings.Join(parts, "")
416}
417
418// protocolFieldViews returns the rendered input fields specific to the given
419// protocol, in display order.
420func (m *Login) protocolFieldViews(proto string) []string {
421 common := []string{
422 m.inputs[inputName].View(),
423 m.inputs[inputEmail].View(),
424 m.inputs[inputFetchEmail].View(),
425 m.inputs[inputSendAsEmail].View(),
426 m.inputs[inputCatchAll].View(),
427 }
428
429 switch proto {
430 case protocolJMAP:
431 return append(common,
432 m.inputs[inputAuthMethod].View(),
433 m.inputs[inputPassword].View(),
434 "",
435 listHeader.Render("JMAP Settings:"),
436 m.inputs[inputJMAPEndpoint].View(),
437 )
438 case protocolPOP3:
439 return append(common,
440 m.inputs[inputPassword].View(),
441 "",
442 listHeader.Render("POP3 Server Settings:"),
443 m.inputs[inputPOP3Server].View(),
444 m.inputs[inputPOP3Port].View(),
445 "",
446 listHeader.Render("SMTP Settings (for sending):"),
447 m.inputs[inputSMTPServer].View(),
448 m.inputs[inputSMTPPort].View(),
449 m.inputs[inputInsecure].View(),
450 )
451 case protocolMaildir:
452 return append(common,
453 "",
454 listHeader.Render("Maildir Settings:"),
455 m.inputs[inputMaildirPath].View(),
456 )
457 default:
458 return m.imapFieldViews(common)
459 }
460}
461
462// imapFieldViews renders the IMAP-specific fields (provider selector, optional
463// OAuth2/password, and custom server settings) appended after the common fields.
464func (m *Login) imapFieldViews(common []string) []string {
465 provider := m.inputs[inputProvider].Value()
466 hasOAuth := provider == "gmail" || provider == "outlook"
467
468 views := append([]string{m.inputs[inputProvider].View()}, common...)
469
470 if hasOAuth {
471 views = append(views, m.inputs[inputAuthMethod].View())
472 }
473
474 if !m.useOAuth2 {
475 views = append(views, m.inputs[inputPassword].View())
476 } else {
477 views = append(views, accountEmailStyle.Render("OAuth2 selected — browser authorization will open after submit"))
478 }
479
480 if m.showCustom {
481 views = append(views,
482 "",
483 accountEmailStyle.Render("Custom provider selected - configure server settings below"),
484 m.inputs[inputIMAPServer].View(),
485 m.inputs[inputIMAPPort].View(),
486 m.inputs[inputSMTPServer].View(),
487 m.inputs[inputSMTPPort].View(),
488 m.inputs[inputInsecure].View(),
489 )
490 }
491
492 return views
493}
494
495// View renders the login form.
496func (m *Login) View() tea.View {
497 title := "Add Account"
498 if m.isEditMode {
499 title = "Edit Account"
500 }
501
502 proto := m.protocol()
503
504 tip := ""
505 switch m.focusIndex {
506 case inputProtocol:
507 tip = "Use ←/→ to choose the protocol: imap (default), jmap, pop3, or maildir."
508 case inputProvider:
509 tip = "Enter your email provider (e.g., gmail, outlook, icloud) or 'custom'."
510 case inputName:
511 tip = "The name that will appear on emails you send."
512 case inputEmail:
513 tip = "Your full email address used to log in."
514 case inputFetchEmail:
515 tip = "The email address to fetch messages for (comma-separated for multiple, e.g. me@icloud.com,me@mac.com)."
516 case inputSendAsEmail:
517 tip = "Optional From header override for outgoing email. Leave blank to send as the fetched address."
518 case inputAuthMethod:
519 if m.protocol() == protocolJMAP {
520 tip = "Type 'token' for API token (Bearer auth, default) or 'password' for HTTP Basic auth."
521 } else {
522 tip = "Type 'oauth2' for OAuth2 or 'password' for app password."
523 }
524 case inputPassword:
525 tip = "Your password or an app-specific password if using 2FA."
526 case inputIMAPServer:
527 tip = "The server address for receiving emails."
528 case inputIMAPPort:
529 tip = "The port for the IMAP server (usually 993 for SSL)."
530 case inputSMTPServer:
531 tip = "The server address for sending emails."
532 case inputSMTPPort:
533 tip = "The port for the SMTP server (usually 587 for TLS)."
534 case inputInsecure:
535 tip = "Type 'true' to disable TLS certificate verification (not recommended)."
536 case inputCatchAll:
537 tip = "Type 'true' to show all inbox messages regardless of To address (useful for catch-all domains)."
538 case inputJMAPEndpoint:
539 tip = "The JMAP session resource URL (e.g., https://api.fastmail.com/jmap/session)."
540 case inputPOP3Server:
541 tip = "The POP3 server address for receiving emails."
542 case inputPOP3Port:
543 tip = "The port for the POP3 server (usually 995 for SSL)."
544 case inputMaildirPath:
545 tip = "Local path to a Maildir directory (cur/new/tmp). Subfolders use .Foldername (Maildir++)."
546 }
547
548 views := []string{
549 titleStyle.Render(title),
550 "Enter your email account credentials.",
551 "",
552 m.viewProtocolCombobox(),
553 }
554
555 views = append(views, m.protocolFieldViews(proto)...)
556
557 views = append(views, "")
558 if !m.hideTips && tip != "" {
559 views = append(views, TipStyle.Render("Tip: "+tip))
560 }
561 helpLine := "enter: save • tab: next field • esc: back to menu"
562 if m.focusIndex == inputProtocol {
563 helpLine += " • ←/→: change protocol"
564 }
565 if m.focusIndex == inputPassword {
566 helpLine += " • ctrl+v: toggle password visibility"
567 }
568 views = append(views, helpStyle.Render("\n"+helpLine))
569
570 return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, views...))
571}
572
573// SetEditMode sets the login form to edit an existing account.
574func (m *Login) SetEditMode(accountID, protocol, provider, name, email, fetchEmail, sendAsEmail, imapServer string, imapPort int, smtpServer string, smtpPort int, insecure bool, jmapEndpoint, pop3Server string, pop3Port int, catchAll bool, maildirPath string) {
575 m.isEditMode = true
576 m.accountID = accountID
577
578 if protocol == "" {
579 protocol = protocolIMAP
580 }
581 m.inputs[inputProtocol].SetValue(protocol)
582 m.inputs[inputProvider].SetValue(provider)
583 m.inputs[inputName].SetValue(name)
584 m.inputs[inputEmail].SetValue(email)
585 m.inputs[inputFetchEmail].SetValue(fetchEmail)
586 m.inputs[inputSendAsEmail].SetValue(sendAsEmail)
587 if catchAll {
588 m.inputs[inputCatchAll].SetValue("true")
589 } else {
590 m.inputs[inputCatchAll].SetValue("false")
591 }
592 m.showCustom = provider == "custom"
593
594 if m.showCustom {
595 m.inputs[inputIMAPServer].SetValue(imapServer)
596 if insecure {
597 m.inputs[inputInsecure].SetValue("true")
598 } else {
599 m.inputs[inputInsecure].SetValue("false")
600 }
601 if imapPort != 0 {
602 m.inputs[inputIMAPPort].SetValue(strconv.Itoa(imapPort))
603 }
604 m.inputs[inputSMTPServer].SetValue(smtpServer)
605 if smtpPort != 0 {
606 m.inputs[inputSMTPPort].SetValue(strconv.Itoa(smtpPort))
607 }
608 }
609
610 if jmapEndpoint != "" {
611 m.inputs[inputJMAPEndpoint].SetValue(jmapEndpoint)
612 }
613 if pop3Server != "" {
614 m.inputs[inputPOP3Server].SetValue(pop3Server)
615 }
616 if pop3Port != 0 {
617 m.inputs[inputPOP3Port].SetValue(strconv.Itoa(pop3Port))
618 }
619 // Also set SMTP for POP3
620 if protocol == protocolPOP3 {
621 m.inputs[inputSMTPServer].SetValue(smtpServer)
622 if smtpPort != 0 {
623 m.inputs[inputSMTPPort].SetValue(strconv.Itoa(smtpPort))
624 }
625 }
626 if maildirPath != "" {
627 m.inputs[inputMaildirPath].SetValue(maildirPath)
628 }
629}
630
631// GetAccountID returns the account ID being edited (if in edit mode).
632func (m *Login) GetAccountID() string {
633 return m.accountID
634}
635
636// IsEditMode returns whether the form is in edit mode.
637func (m *Login) IsEditMode() bool {
638 return m.isEditMode
639}