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