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