login.go

  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}