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