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