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