aboutsummaryrefslogtreecommitdiffstats
path: root/app/account-wizard.go
diff options
context:
space:
mode:
authorRobin Jarry <robin@jarry.cc>2023-10-09 13:52:20 +0200
committerRobin Jarry <robin@jarry.cc>2023-10-10 11:37:56 +0200
commit598e4a5803578ab3e291f232d6aad31b4efd8ea4 (patch)
treec55e16d60e2c3eea2d6de27d1bac18db5670ec77 /app/account-wizard.go
parent61bca76423ee87bd59084a146eca71c6bae085e1 (diff)
downloadaerc-598e4a5803578ab3e291f232d6aad31b4efd8ea4.tar.gz
widgets: rename package to app
This is the central point of all aerc. Having it named widgets is confusing. Rename it to app. It will make a cleaner transition when making the app.Aerc object available globally in the next commit. Signed-off-by: Robin Jarry <robin@jarry.cc> Acked-by: Moritz Poldrack <moritz@poldrack.dev>
Diffstat (limited to 'app/account-wizard.go')
-rw-r--r--app/account-wizard.go891
1 files changed, 891 insertions, 0 deletions
diff --git a/app/account-wizard.go b/app/account-wizard.go
new file mode 100644
index 00000000..db3aae2f
--- /dev/null
+++ b/app/account-wizard.go
@@ -0,0 +1,891 @@
+package app
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "net/url"
+ "os"
+ "os/exec"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/emersion/go-message/mail"
+ "github.com/gdamore/tcell/v2"
+ "github.com/go-ini/ini"
+ "golang.org/x/sys/unix"
+
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib/format"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+ "git.sr.ht/~rjarry/aerc/lib/xdg"
+ "git.sr.ht/~rjarry/aerc/log"
+)
+
+const (
+ CONFIGURE_BASICS = iota
+ CONFIGURE_SOURCE = iota
+ CONFIGURE_OUTGOING = iota
+ CONFIGURE_COMPLETE = iota
+)
+
+type AccountWizard struct {
+ aerc *Aerc
+ step int
+ steps []*ui.Grid
+ focus int
+ temporary bool
+ // CONFIGURE_BASICS
+ accountName *ui.TextInput
+ email *ui.TextInput
+ discovered map[string]string
+ fullName *ui.TextInput
+ basics []ui.Interactive
+ // CONFIGURE_SOURCE
+ sourceProtocol *Selector
+ sourceTransport *Selector
+
+ sourceUsername *ui.TextInput
+ sourcePassword *ui.TextInput
+ sourceServer *ui.TextInput
+ sourceStr *ui.Text
+ sourceUrl url.URL
+ source []ui.Interactive
+ // CONFIGURE_OUTGOING
+ outgoingProtocol *Selector
+ outgoingTransport *Selector
+
+ outgoingUsername *ui.TextInput
+ outgoingPassword *ui.TextInput
+ outgoingServer *ui.TextInput
+ outgoingStr *ui.Text
+ outgoingUrl url.URL
+ outgoingCopyTo *ui.TextInput
+ outgoing []ui.Interactive
+ // CONFIGURE_COMPLETE
+ complete []ui.Interactive
+}
+
+func showPasswordWarning(aerc *Aerc) {
+ title := "ATTENTION"
+ text := `
+The Wizard will store your passwords as clear text in:
+
+ ~/.config/aerc/accounts.conf
+
+It is recommended to remove the clear text passwords and configure
+'source-cred-cmd' and 'outgoing-cred-cmd' using your own password store
+after the setup.
+`
+ warning := NewSelectorDialog(
+ title, text, []string{"OK"}, 0,
+ aerc.SelectedAccountUiConfig(),
+ func(_ string, _ error) {
+ aerc.CloseDialog()
+ },
+ )
+ aerc.AddDialog(warning)
+}
+
+type configStep struct {
+ introduction string
+ labels []string
+ fields []ui.Drawable
+ interactive *[]ui.Interactive
+}
+
+func NewConfigStep(intro string, interactive *[]ui.Interactive) configStep {
+ return configStep{introduction: intro, interactive: interactive}
+}
+
+func (s *configStep) AddField(label string, field ui.Drawable) {
+ s.labels = append(s.labels, label)
+ s.fields = append(s.fields, field)
+ if i, ok := field.(ui.Interactive); ok {
+ *s.interactive = append(*s.interactive, i)
+ }
+}
+
+func (s *configStep) Grid() *ui.Grid {
+ introduction := strings.TrimSpace(s.introduction)
+ h := strings.Count(introduction, "\n") + 1
+ spec := []ui.GridSpec{
+ {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, // padding
+ {Strategy: ui.SIZE_EXACT, Size: ui.Const(h)}, // intro text
+ {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, // padding
+ }
+ for range s.fields {
+ spec = append(spec, []ui.GridSpec{
+ {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, // label
+ {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, // field
+ {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, // padding
+ }...)
+ }
+ justify := ui.GridSpec{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}
+ spec = append(spec, justify)
+ grid := ui.NewGrid().Rows(spec).Columns([]ui.GridSpec{justify})
+
+ intro := ui.NewText(introduction, config.Ui.GetStyle(config.STYLE_DEFAULT))
+ fill := ui.NewFill(' ', tcell.StyleDefault)
+
+ grid.AddChild(fill).At(0, 0)
+ grid.AddChild(intro).At(1, 0)
+ grid.AddChild(fill).At(2, 0)
+
+ row := 3
+ for i, field := range s.fields {
+ label := ui.NewText(s.labels[i], config.Ui.GetStyle(config.STYLE_HEADER))
+ grid.AddChild(label).At(row, 0)
+ grid.AddChild(field).At(row+1, 0)
+ grid.AddChild(fill).At(row+2, 0)
+ row += 3
+ }
+
+ grid.AddChild(fill).At(row, 0)
+
+ return grid
+}
+
+const (
+ // protocols
+ IMAP = "IMAP"
+ JMAP = "JMAP"
+ MAILDIR = "Maildir"
+ MAILDIRPP = "Maildir++"
+ NOTMUCH = "notmuch"
+ SMTP = "SMTP"
+ SENDMAIL = "sendmail"
+ // transports
+ SSL_TLS = "SSL/TLS"
+ OAUTH = "SSL/TLS+OAUTHBEARER"
+ XOAUTH = "SSL/TLS+XOAUTH2"
+ STARTTLS = "STARTTLS"
+ INSECURE = "Insecure"
+)
+
+var (
+ sources = []string{IMAP, JMAP, MAILDIR, MAILDIRPP, NOTMUCH}
+ outgoings = []string{SMTP, JMAP, SENDMAIL}
+ transports = []string{SSL_TLS, OAUTH, XOAUTH, STARTTLS, INSECURE}
+)
+
+func NewAccountWizard(aerc *Aerc) *AccountWizard {
+ wizard := &AccountWizard{
+ accountName: ui.NewTextInput("", config.Ui).Prompt("> "),
+ aerc: aerc,
+ temporary: false,
+ email: ui.NewTextInput("", config.Ui).Prompt("> "),
+ fullName: ui.NewTextInput("", config.Ui).Prompt("> "),
+ sourcePassword: ui.NewTextInput("", config.Ui).Prompt("] ").Password(true),
+ sourceServer: ui.NewTextInput("", config.Ui).Prompt("> "),
+ sourceStr: ui.NewText("", config.Ui.GetStyle(config.STYLE_DEFAULT)),
+ sourceUsername: ui.NewTextInput("", config.Ui).Prompt("> "),
+ outgoingPassword: ui.NewTextInput("", config.Ui).Prompt("] ").Password(true),
+ outgoingServer: ui.NewTextInput("", config.Ui).Prompt("> "),
+ outgoingStr: ui.NewText("", config.Ui.GetStyle(config.STYLE_DEFAULT)),
+ outgoingUsername: ui.NewTextInput("", config.Ui).Prompt("> "),
+ outgoingCopyTo: ui.NewTextInput("", config.Ui).Prompt("> "),
+
+ sourceProtocol: NewSelector(sources, 0, config.Ui).Chooser(true),
+ sourceTransport: NewSelector(transports, 0, config.Ui).Chooser(true),
+ outgoingProtocol: NewSelector(outgoings, 0, config.Ui).Chooser(true),
+ outgoingTransport: NewSelector(transports, 0, config.Ui).Chooser(true),
+ }
+
+ // Autofill some stuff for the user
+ wizard.email.OnFocusLost(func(_ *ui.TextInput) {
+ value := wizard.email.String()
+ if wizard.sourceUsername.String() == "" {
+ wizard.sourceUsername.Set(value)
+ }
+ if wizard.outgoingUsername.String() == "" {
+ wizard.outgoingUsername.Set(value)
+ }
+ wizard.sourceUri()
+ wizard.outgoingUri()
+ })
+ wizard.sourceProtocol.OnSelect(func(option string) {
+ wizard.sourceServer.Set("")
+ wizard.autofill()
+ wizard.sourceUri()
+ })
+ wizard.sourceServer.OnChange(func(_ *ui.TextInput) {
+ wizard.sourceUri()
+ })
+ wizard.sourceServer.OnFocusLost(func(_ *ui.TextInput) {
+ src := wizard.sourceServer.String()
+ out := wizard.outgoingServer.String()
+ if out == "" && strings.HasPrefix(src, "imap.") {
+ out = strings.Replace(src, "imap.", "smtp.", 1)
+ wizard.outgoingServer.Set(out)
+ }
+ wizard.outgoingUri()
+ })
+ wizard.sourceUsername.OnChange(func(_ *ui.TextInput) {
+ wizard.sourceUri()
+ })
+ wizard.sourceUsername.OnFocusLost(func(_ *ui.TextInput) {
+ if wizard.outgoingUsername.String() == "" {
+ wizard.outgoingUsername.Set(wizard.sourceUsername.String())
+ wizard.outgoingUri()
+ }
+ })
+ wizard.sourceTransport.OnSelect(func(option string) {
+ wizard.sourceUri()
+ })
+ var once sync.Once
+ wizard.sourcePassword.OnChange(func(_ *ui.TextInput) {
+ wizard.outgoingPassword.Set(wizard.sourcePassword.String())
+ wizard.sourceUri()
+ wizard.outgoingUri()
+ })
+ wizard.sourcePassword.OnFocusLost(func(_ *ui.TextInput) {
+ if wizard.sourcePassword.String() != "" {
+ once.Do(func() {
+ showPasswordWarning(aerc)
+ })
+ }
+ })
+ wizard.outgoingProtocol.OnSelect(func(option string) {
+ wizard.outgoingServer.Set("")
+ wizard.autofill()
+ wizard.outgoingUri()
+ })
+ wizard.outgoingServer.OnChange(func(_ *ui.TextInput) {
+ wizard.outgoingUri()
+ })
+ wizard.outgoingUsername.OnChange(func(_ *ui.TextInput) {
+ wizard.outgoingUri()
+ })
+ wizard.outgoingPassword.OnChange(func(_ *ui.TextInput) {
+ if wizard.outgoingPassword.String() != "" {
+ once.Do(func() {
+ showPasswordWarning(aerc)
+ })
+ }
+ wizard.outgoingUri()
+ })
+ wizard.outgoingTransport.OnSelect(func(option string) {
+ wizard.outgoingUri()
+ })
+
+ // CONFIGURE_BASICS
+ basics := NewConfigStep(
+ `
+Welcome to aerc! Let's configure your account.
+
+Key bindings:
+
+ <Tab>, <Down> or <Ctrl+j> Next field
+ <Shift+Tab>, <Up> or <Ctrl+k> Previous field
+ <Ctrl+q> Exit aerc
+`,
+ &wizard.basics,
+ )
+ basics.AddField(
+ "Name for this account? (e.g. 'Personal' or 'Work')",
+ wizard.accountName,
+ )
+ basics.AddField(
+ "Full name for outgoing emails? (e.g. 'John Doe')",
+ wizard.fullName,
+ )
+ basics.AddField(
+ "Your email address? (e.g. 'john@example.org')",
+ wizard.email,
+ )
+ basics.AddField("", NewSelector([]string{"Next"}, 0, config.Ui).
+ OnChoose(func(option string) {
+ wizard.discoverServices()
+ wizard.autofill()
+ wizard.sourceUri()
+ wizard.outgoingUri()
+ wizard.advance(option)
+ }),
+ )
+
+ // CONFIGURE_SOURCE
+ source := NewConfigStep("Configure email source", &wizard.source)
+ source.AddField("Protocol", wizard.sourceProtocol)
+ source.AddField("Username", wizard.sourceUsername)
+ source.AddField("Password", wizard.sourcePassword)
+ source.AddField(
+ "Server address (or path to email store)",
+ wizard.sourceServer,
+ )
+ source.AddField("Transport security", wizard.sourceTransport)
+ source.AddField("Connection URL", wizard.sourceStr)
+ source.AddField(
+ "", NewSelector([]string{"Previous", "Next"}, 1, config.Ui).
+ OnChoose(wizard.advance),
+ )
+
+ // CONFIGURE_OUTGOING
+ outgoing := NewConfigStep("Configure outgoing mail", &wizard.outgoing)
+ outgoing.AddField("Protocol", wizard.outgoingProtocol)
+ outgoing.AddField("Username", wizard.outgoingUsername)
+ outgoing.AddField("Password", wizard.outgoingPassword)
+ outgoing.AddField(
+ "Server address (or path to sendmail)",
+ wizard.outgoingServer,
+ )
+ outgoing.AddField("Transport security", wizard.outgoingTransport)
+ outgoing.AddField("Connection URL", wizard.outgoingStr)
+ outgoing.AddField(
+ "Copy sent messages to folder (leave empty to disable)",
+ wizard.outgoingCopyTo,
+ )
+ outgoing.AddField(
+ "", NewSelector([]string{"Previous", "Next"}, 1, config.Ui).
+ OnChoose(wizard.advance),
+ )
+
+ // CONFIGURE_COMPLETE
+ complete := NewConfigStep(
+ fmt.Sprintf(`
+Configuration complete!
+
+You can go back and double check your settings, or choose [Finish] to
+save your settings to %s/accounts.conf.
+
+Make sure to review the contents of this file and read the
+aerc-accounts(5) man page for guidance and further tweaking.
+
+To add another account in the future, run ':new-account'.
+`, xdg.TildeHome(xdg.ConfigPath("aerc"))),
+ &wizard.complete,
+ )
+ complete.AddField(
+ "", NewSelector([]string{
+ "Previous",
+ "Finish & open tutorial",
+ "Finish",
+ }, 1, config.Ui).OnChoose(func(option string) {
+ switch option {
+ case "Previous":
+ wizard.advance("Previous")
+ case "Finish & open tutorial":
+ wizard.finish(true)
+ case "Finish":
+ wizard.finish(false)
+ }
+ }),
+ )
+
+ wizard.steps = []*ui.Grid{
+ basics.Grid(), source.Grid(), outgoing.Grid(), complete.Grid(),
+ }
+
+ return wizard
+}
+
+func (wizard *AccountWizard) ConfigureTemporaryAccount(temporary bool) {
+ wizard.temporary = temporary
+}
+
+func (wizard *AccountWizard) errorFor(d ui.Interactive, err error) {
+ if d == nil {
+ wizard.aerc.PushError(err.Error())
+ wizard.Invalidate()
+ return
+ }
+ for step, interactives := range [][]ui.Interactive{
+ wizard.basics,
+ wizard.source,
+ wizard.outgoing,
+ } {
+ for focus, item := range interactives {
+ if item == d {
+ wizard.Focus(false)
+ wizard.step = step
+ wizard.focus = focus
+ wizard.Focus(true)
+ wizard.aerc.PushError(err.Error())
+ wizard.Invalidate()
+ return
+ }
+ }
+ }
+}
+
+func (wizard *AccountWizard) finish(tutorial bool) {
+ accountsConf := xdg.ConfigPath("aerc", "accounts.conf")
+
+ // Validation
+ if wizard.accountName.String() == "" {
+ wizard.errorFor(wizard.accountName,
+ errors.New("Account name is required"))
+ return
+ }
+ if wizard.email.String() == "" {
+ wizard.errorFor(wizard.email,
+ errors.New("Email address is required"))
+ return
+ }
+ if wizard.sourceServer.String() == "" {
+ wizard.errorFor(wizard.sourceServer,
+ errors.New("Email source configuration is required"))
+ return
+ }
+ if wizard.outgoingServer.String() == "" &&
+ wizard.outgoingProtocol.Selected() != JMAP {
+ wizard.errorFor(wizard.outgoingServer,
+ errors.New("Outgoing mail configuration is required"))
+ return
+ }
+ switch wizard.sourceProtocol.Selected() {
+ case MAILDIR, MAILDIRPP, NOTMUCH:
+ path := xdg.ExpandHome(wizard.sourceServer.String())
+ s, err := os.Stat(path)
+ if err == nil && !s.IsDir() {
+ err = fmt.Errorf("%s: Not a directory", s.Name())
+ }
+ if err == nil {
+ err = unix.Access(path, unix.X_OK)
+ }
+ if err != nil {
+ wizard.errorFor(wizard.sourceServer, err)
+ return
+ }
+ }
+ if wizard.outgoingProtocol.Selected() == SENDMAIL {
+ path := xdg.ExpandHome(wizard.outgoingServer.String())
+ s, err := os.Stat(path)
+ if err == nil && !s.Mode().IsRegular() {
+ err = fmt.Errorf("%s: Not a regular file", s.Name())
+ }
+ if err == nil {
+ err = unix.Access(path, unix.X_OK)
+ }
+ if err != nil {
+ wizard.errorFor(wizard.outgoingServer, err)
+ return
+ }
+ }
+
+ file, err := ini.Load(accountsConf)
+ if err != nil {
+ file = ini.Empty()
+ }
+
+ var sec *ini.Section
+ if sec, _ = file.GetSection(wizard.accountName.String()); sec != nil {
+ wizard.errorFor(wizard.accountName,
+ errors.New("An account by this name already exists"))
+ return
+ }
+ sec, _ = file.NewSection(wizard.accountName.String())
+ // these can't fail
+ _, _ = sec.NewKey("source", wizard.sourceUrl.String())
+ _, _ = sec.NewKey("outgoing", wizard.outgoingUrl.String())
+ _, _ = sec.NewKey("default", "INBOX")
+ from := mail.Address{
+ Name: wizard.fullName.String(),
+ Address: wizard.email.String(),
+ }
+ _, _ = sec.NewKey("from", format.AddressForHumans(&from))
+ if wizard.outgoingCopyTo.String() != "" {
+ _, _ = sec.NewKey("copy-to", wizard.outgoingCopyTo.String())
+ }
+
+ switch wizard.sourceProtocol.Selected() {
+ case IMAP:
+ _, _ = sec.NewKey("cache-headers", "true")
+ case JMAP:
+ _, _ = sec.NewKey("use-labels", "true")
+ _, _ = sec.NewKey("cache-state", "true")
+ _, _ = sec.NewKey("cache-blobs", "false")
+ case NOTMUCH:
+ cmd := exec.Command("notmuch", "config", "get", "database.mail_root")
+ out, err := cmd.Output()
+ if err == nil {
+ root := strings.TrimSpace(string(out))
+ _, _ = sec.NewKey("maildir-store", xdg.TildeHome(root))
+ }
+ querymap := ini.Empty()
+ def := querymap.Section("")
+ cmd = exec.Command("notmuch", "config", "list")
+ out, err = cmd.Output()
+ if err == nil {
+ re := regexp.MustCompile(`(?m)^query\.([^=]+)=(.+)$`)
+ for _, m := range re.FindAllStringSubmatch(string(out), -1) {
+ _, _ = def.NewKey(m[1], m[2])
+ }
+ }
+ if len(def.Keys()) == 0 {
+ _, _ = def.NewKey("INBOX", "tag:inbox and not tag:archived")
+ }
+ if !wizard.temporary {
+ qmapPath := xdg.ConfigPath("aerc",
+ wizard.accountName.String()+".qmap")
+ f, err := os.OpenFile(qmapPath, os.O_WRONLY|os.O_CREATE, 0o600)
+ if err != nil {
+ wizard.errorFor(nil, err)
+ return
+ }
+ defer f.Close()
+ if _, err = querymap.WriteTo(f); err != nil {
+ wizard.errorFor(nil, err)
+ return
+ }
+ _, _ = sec.NewKey("query-map", xdg.TildeHome(qmapPath))
+ }
+ }
+
+ if !wizard.temporary {
+ f, err := os.OpenFile(accountsConf, os.O_WRONLY|os.O_CREATE, 0o600)
+ if err != nil {
+ wizard.errorFor(nil, err)
+ return
+ }
+ defer f.Close()
+ if _, err = file.WriteTo(f); err != nil {
+ wizard.errorFor(nil, err)
+ return
+ }
+ }
+
+ account, err := config.ParseAccountConfig(sec.Name(), sec)
+ if err != nil {
+ wizard.errorFor(nil, err)
+ return
+ }
+ config.Accounts = append(config.Accounts, account)
+
+ view, err := NewAccountView(wizard.aerc, account, wizard.aerc, nil)
+ if err != nil {
+ wizard.aerc.NewTab(errorScreen(err.Error()), account.Name)
+ return
+ }
+ wizard.aerc.accounts[account.Name] = view
+ wizard.aerc.NewTab(view, account.Name)
+
+ if tutorial {
+ name := "aerc-tutorial"
+ if _, err := os.Stat("./aerc-tutorial.7"); !os.IsNotExist(err) {
+ // For development
+ name = "./aerc-tutorial.7"
+ }
+ term, err := NewTerminal(exec.Command("man", name))
+ if err != nil {
+ wizard.errorFor(nil, err)
+ return
+ }
+ wizard.aerc.NewTab(term, "Tutorial")
+ term.OnClose = func(err error) {
+ wizard.aerc.RemoveTab(term, false)
+ if err != nil {
+ wizard.aerc.PushError(err.Error())
+ }
+ }
+ }
+
+ wizard.aerc.RemoveTab(wizard, false)
+}
+
+func splitHostPath(server string) (string, string) {
+ host, path, found := strings.Cut(server, "/")
+ if found {
+ path = "/" + path
+ }
+ return host, path
+}
+
+func makeURLs(scheme, host, path, user, pass string) (url.URL, url.URL) {
+ var opaque string
+
+ // If everything is unset, the rendered URL is '<scheme>:'.
+ // Force a '//' opaque suffix so that it is rendered as '<scheme>://'.
+ if scheme != "" && host == "" && path == "" && user == "" && pass == "" {
+ opaque = "//"
+ }
+
+ uri := url.URL{Scheme: scheme, Host: host, Path: path, Opaque: opaque}
+ clean := uri
+
+ switch {
+ case pass != "":
+ uri.User = url.UserPassword(user, pass)
+ clean.User = url.UserPassword(user, strings.Repeat("*", len(pass)))
+ case user != "":
+ uri.User = url.User(user)
+ clean.User = url.User(user)
+ }
+
+ return uri, clean
+}
+
+func (wizard *AccountWizard) sourceUri() url.URL {
+ host, path := splitHostPath(wizard.sourceServer.String())
+ user := wizard.sourceUsername.String()
+ pass := wizard.sourcePassword.String()
+ var scheme string
+ switch wizard.sourceProtocol.Selected() {
+ case IMAP:
+ switch wizard.sourceTransport.Selected() {
+ case STARTTLS:
+ scheme = "imap"
+ case INSECURE:
+ scheme = "imap+insecure"
+ case OAUTH:
+ scheme = "imaps+oauthbearer"
+ case XOAUTH:
+ scheme = "imaps+xoauth2"
+ default:
+ scheme = "imaps"
+ }
+ case JMAP:
+ switch wizard.sourceTransport.Selected() {
+ case OAUTH:
+ scheme = "jmap+oauthbearer"
+ default:
+ scheme = "jmap"
+ }
+ case MAILDIR:
+ scheme = "maildir"
+ case MAILDIRPP:
+ scheme = "maildirpp"
+ case NOTMUCH:
+ scheme = "notmuch"
+ }
+ switch wizard.sourceProtocol.Selected() {
+ case MAILDIR, MAILDIRPP, NOTMUCH:
+ path = host + path
+ host = ""
+ user = ""
+ pass = ""
+ }
+
+ uri, clean := makeURLs(scheme, host, path, user, pass)
+
+ wizard.sourceStr.Text(
+ " " + strings.ReplaceAll(clean.String(), "%2A", "*"))
+ wizard.sourceUrl = uri
+ return uri
+}
+
+func (wizard *AccountWizard) outgoingUri() url.URL {
+ host, path := splitHostPath(wizard.outgoingServer.String())
+ user := wizard.outgoingUsername.String()
+ pass := wizard.outgoingPassword.String()
+ var scheme string
+ switch wizard.outgoingProtocol.Selected() {
+ case SMTP:
+ switch wizard.outgoingTransport.Selected() {
+ case OAUTH:
+ scheme = "smtps+oauthbearer"
+ case XOAUTH:
+ scheme = "smtps+xoauth2"
+ case INSECURE:
+ scheme = "smtp+insecure"
+ case STARTTLS:
+ scheme = "smtp"
+ default:
+ scheme = "smtps"
+ }
+ case JMAP:
+ switch wizard.outgoingTransport.Selected() {
+ case OAUTH:
+ scheme = "jmap+oauthbearer"
+ default:
+ scheme = "jmap"
+ }
+ case SENDMAIL:
+ scheme = ""
+ path = host + path
+ host = ""
+ user = ""
+ pass = ""
+ }
+
+ uri, clean := makeURLs(scheme, host, path, user, pass)
+
+ wizard.outgoingStr.Text(
+ " " + strings.ReplaceAll(clean.String(), "%2A", "*"))
+ wizard.outgoingUrl = uri
+ return uri
+}
+
+func (wizard *AccountWizard) Invalidate() {
+ ui.Invalidate()
+}
+
+func (wizard *AccountWizard) Draw(ctx *ui.Context) {
+ wizard.steps[wizard.step].Draw(ctx)
+}
+
+func (wizard *AccountWizard) getInteractive() []ui.Interactive {
+ switch wizard.step {
+ case CONFIGURE_BASICS:
+ return wizard.basics
+ case CONFIGURE_SOURCE:
+ return wizard.source
+ case CONFIGURE_OUTGOING:
+ return wizard.outgoing
+ case CONFIGURE_COMPLETE:
+ return wizard.complete
+ }
+ return nil
+}
+
+func (wizard *AccountWizard) advance(direction string) {
+ wizard.Focus(false)
+ if direction == "Next" && wizard.step < len(wizard.steps)-1 {
+ wizard.step++
+ }
+ if direction == "Previous" && wizard.step > 0 {
+ wizard.step--
+ }
+ wizard.focus = 0
+ wizard.Focus(true)
+ wizard.Invalidate()
+}
+
+func (wizard *AccountWizard) Focus(focus bool) {
+ if interactive := wizard.getInteractive(); interactive != nil {
+ interactive[wizard.focus].Focus(focus)
+ }
+}
+
+func (wizard *AccountWizard) Event(event tcell.Event) bool {
+ interactive := wizard.getInteractive()
+ if event, ok := event.(*tcell.EventKey); ok {
+ switch event.Key() {
+ case tcell.KeyUp:
+ fallthrough
+ case tcell.KeyBacktab:
+ fallthrough
+ case tcell.KeyCtrlK:
+ if interactive != nil {
+ interactive[wizard.focus].Focus(false)
+ wizard.focus--
+ if wizard.focus < 0 {
+ wizard.focus = len(interactive) - 1
+ }
+ interactive[wizard.focus].Focus(true)
+ }
+ wizard.Invalidate()
+ return true
+ case tcell.KeyDown:
+ fallthrough
+ case tcell.KeyTab:
+ fallthrough
+ case tcell.KeyCtrlJ:
+ if interactive != nil {
+ interactive[wizard.focus].Focus(false)
+ wizard.focus++
+ if wizard.focus >= len(interactive) {
+ wizard.focus = 0
+ }
+ interactive[wizard.focus].Focus(true)
+ }
+ wizard.Invalidate()
+ return true
+ }
+ }
+ if interactive != nil {
+ return interactive[wizard.focus].Event(event)
+ }
+ return false
+}
+
+func (wizard *AccountWizard) discoverServices() {
+ email := wizard.email.String()
+ if !strings.ContainsRune(email, '@') {
+ return
+ }
+ domain := email[strings.IndexRune(email, '@')+1:]
+ var wg sync.WaitGroup
+ type Service struct{ srv, hostport string }
+ services := make(chan Service)
+
+ for _, service := range []string{"imaps", "imap", "submission", "jmap"} {
+ wg.Add(1)
+ go func(srv string) {
+ defer log.PanicHandler()
+ defer wg.Done()
+ _, addrs, err := net.LookupSRV(srv, "tcp", domain)
+ if err != nil {
+ log.Tracef("SRV lookup for _%s._tcp.%s failed: %s",
+ srv, domain, err)
+ } else if addrs[0].Target != "" && addrs[0].Port > 0 {
+ services <- Service{
+ srv: srv,
+ hostport: net.JoinHostPort(
+ strings.TrimSuffix(addrs[0].Target, "."),
+ strconv.Itoa(int(addrs[0].Port))),
+ }
+ }
+ }(service)
+ }
+ go func() {
+ defer log.PanicHandler()
+ wg.Wait()
+ close(services)
+ }()
+
+ wizard.discovered = make(map[string]string)
+ for s := range services {
+ wizard.discovered[s.srv] = s.hostport
+ }
+}
+
+func (wizard *AccountWizard) autofill() {
+ if wizard.sourceServer.String() == "" {
+ switch wizard.sourceProtocol.Selected() {
+ case IMAP:
+ if s, ok := wizard.discovered["imaps"]; ok {
+ wizard.sourceServer.Set(s)
+ wizard.sourceTransport.Select(SSL_TLS)
+ } else if s, ok := wizard.discovered["imap"]; ok {
+ wizard.sourceServer.Set(s)
+ wizard.sourceTransport.Select(STARTTLS)
+ }
+ case JMAP:
+ if s, ok := wizard.discovered["jmap"]; ok {
+ s = strings.TrimSuffix(s, ":443")
+ wizard.sourceServer.Set(s + "/.well-known/jmap")
+ wizard.sourceTransport.Select(SSL_TLS)
+ }
+ case MAILDIR, MAILDIRPP:
+ wizard.sourceServer.Set("~/mail")
+ wizard.sourceUsername.Set("")
+ wizard.sourcePassword.Set("")
+ case NOTMUCH:
+ cmd := exec.Command("notmuch", "config", "get", "database.path")
+ out, err := cmd.Output()
+ if err == nil {
+ db := strings.TrimSpace(string(out))
+ wizard.sourceServer.Set(xdg.TildeHome(db))
+ } else {
+ wizard.sourceServer.Set("~/mail")
+ }
+ wizard.sourceUsername.Set("")
+ wizard.sourcePassword.Set("")
+ }
+ }
+ if wizard.outgoingServer.String() == "" {
+ switch wizard.outgoingProtocol.Selected() {
+ case SMTP:
+ if s, ok := wizard.discovered["submission"]; ok {
+ switch {
+ case strings.HasSuffix(s, ":587"):
+ wizard.outgoingTransport.Select(SSL_TLS)
+ case strings.HasSuffix(s, ":465"):
+ wizard.outgoingTransport.Select(STARTTLS)
+ default:
+ wizard.outgoingTransport.Select(INSECURE)
+ }
+ wizard.outgoingServer.Set(s)
+ }
+ case JMAP:
+ wizard.outgoingTransport.Select(SSL_TLS)
+ case SENDMAIL:
+ wizard.outgoingServer.Set("/usr/sbin/sendmail")
+ wizard.outgoingUsername.Set("")
+ wizard.outgoingPassword.Set("")
+ }
+ }
+}