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	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}