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