about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorDrew DeVault <sir@cmpwn.com>2019-05-21 16:31:04 -0400
committerDrew DeVault <sir@cmpwn.com>2019-05-21 16:53:50 -0400
commit6811143925384ba1cfda8b3e1b338b0cfb9ac6e3 (patch)
tree584ce88b40ab87828d6adbfdb6bef7a5d3046600
parent176245208d40a9ca2ec324be7863a22819de29bc (diff)
downloadaerc-6811143925384ba1cfda8b3e1b338b0cfb9ac6e3.tar.gz
New account wizard, part one
-rw-r--r--commands/new-account.go20
-rw-r--r--config/config.go9
-rw-r--r--doc/aerc.1.scd7
-rw-r--r--lib/ui/text.go2
-rw-r--r--lib/ui/textinput.go31
-rw-r--r--widgets/account-wizard.go625
-rw-r--r--widgets/aerc.go2
7 files changed, 683 insertions, 13 deletions
diff --git a/commands/new-account.go b/commands/new-account.go
new file mode 100644
index 0000000..6a64eb2
--- /dev/null
+++ b/commands/new-account.go
@@ -0,0 +1,20 @@
+package commands
+
+import (
+	"errors"
+
+	"git.sr.ht/~sircmpwn/aerc/widgets"
+)
+
+func init() {
+	register("new-account", CommandNewAccount)
+}
+
+func CommandNewAccount(aerc *widgets.Aerc, args []string) error {
+	if len(args) != 1 {
+		return errors.New("Usage: new-account")
+	}
+	wizard := widgets.NewAccountWizard()
+	aerc.NewTab(wizard, "New account")
+	return nil
+}
diff --git a/config/config.go b/config/config.go
index d885402..c6136cf 100644
--- a/config/config.go
+++ b/config/config.go
@@ -11,6 +11,7 @@ import (
 	"strings"
 	"unicode"
 
+	"github.com/gdamore/tcell"
 	"github.com/go-ini/ini"
 	"github.com/kyoh86/xdg"
 )
@@ -45,6 +46,7 @@ type AccountConfig struct {
 
 type BindingConfig struct {
 	Global        *KeyBindings
+	AccountWizard *KeyBindings
 	Compose       *KeyBindings
 	ComposeEditor *KeyBindings
 	ComposeReview *KeyBindings
@@ -208,6 +210,7 @@ func LoadConfig(root *string) (*AercConfig, error) {
 	config := &AercConfig{
 		Bindings: BindingConfig{
 			Global:        NewKeyBindings(),
+			AccountWizard: NewKeyBindings(),
 			Compose:       NewKeyBindings(),
 			ComposeEditor: NewKeyBindings(),
 			ComposeReview: NewKeyBindings(),
@@ -229,6 +232,12 @@ func LoadConfig(root *string) (*AercConfig, error) {
 			EmptyMessage:      "(no messages)",
 		},
 	}
+	// These bindings are not configurable
+	config.Bindings.AccountWizard.ExKey = KeyStroke{
+		Key: tcell.KeyCtrlE,
+	}
+	quit, _ := ParseBinding("<C-q>", ":quit<Enter>")
+	config.Bindings.AccountWizard.Add(quit)
 	if filters, err := file.GetSection("filters"); err == nil {
 		// TODO: Parse the filter more finely, e.g. parse the regex
 		for _, match := range filters.KeyStrings() {
diff --git a/doc/aerc.1.scd b/doc/aerc.1.scd
index e96334d..459ec62 100644
--- a/doc/aerc.1.scd
+++ b/doc/aerc.1.scd
@@ -8,12 +8,13 @@ aerc - the world's best email client
 
 _aerc_
 
-Starts the interactive aerc mail client on /dev/tty.
+For a guided tutorial, use *:help tutorial*.
 
 # RUNTIME COMMANDS
 
-To execute a command, press : to summon the command interface. Commands may also
-be bound to keys, see *aerc-config*(5) for details.
+To execute a command, press ':' to bring up the command interface. Commands may
+also be bound to keys, see *aerc-config*(5) for details. In some contexts, such
+as the terminal emulator, ';' is used to bring up the command interface.
 
 Different commands work in different contexts, depending on the kind of tab you
 have selected.
diff --git a/lib/ui/text.go b/lib/ui/text.go
index 8aea8eb..2b82598 100644
--- a/lib/ui/text.go
+++ b/lib/ui/text.go
@@ -77,7 +77,7 @@ func (t *Text) Draw(ctx *Context) {
 		style = style.Reverse(true)
 	}
 	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
-	ctx.Printf(x, 0, style, t.text)
+	ctx.Printf(x, 0, style, "%s", t.text)
 }
 
 func (t *Text) Invalidate() {
diff --git a/lib/ui/textinput.go b/lib/ui/textinput.go
index 4a5308e..ce443c3 100644
--- a/lib/ui/textinput.go
+++ b/lib/ui/textinput.go
@@ -10,14 +10,15 @@ import (
 
 type TextInput struct {
 	Invalidatable
-	cells  int
-	ctx    *Context
-	focus  bool
-	index  int
-	prompt string
-	scroll int
-	text   []rune
-	change []func(ti *TextInput)
+	cells    int
+	ctx      *Context
+	focus    bool
+	index    int
+	password bool
+	prompt   string
+	scroll   int
+	text     []rune
+	change   []func(ti *TextInput)
 }
 
 // Creates a new TextInput. TextInputs will render a "textbox" in the entire
@@ -31,6 +32,11 @@ func NewTextInput(text string) *TextInput {
 	}
 }
 
+func (ti *TextInput) Password(password bool) *TextInput {
+	ti.password = password
+	return ti
+}
+
 func (ti *TextInput) Prompt(prompt string) *TextInput {
 	ti.prompt = prompt
 	return ti
@@ -42,6 +48,7 @@ func (ti *TextInput) String() string {
 
 func (ti *TextInput) Set(value string) {
 	ti.text = []rune(value)
+	ti.index = len(ti.text)
 }
 
 func (ti *TextInput) Invalidate() {
@@ -51,7 +58,13 @@ func (ti *TextInput) Invalidate() {
 func (ti *TextInput) Draw(ctx *Context) {
 	ti.ctx = ctx // gross
 	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
-	ctx.Printf(0, 0, tcell.StyleDefault, "%s%s", ti.prompt, string(ti.text))
+	if ti.password {
+		x := ctx.Printf(0, 0, tcell.StyleDefault, "%s", ti.prompt)
+		cells := runewidth.StringWidth(string(ti.text))
+		ctx.Fill(x, 0, cells, 1, '*', tcell.StyleDefault)
+	} else {
+		ctx.Printf(0, 0, tcell.StyleDefault, "%s%s", ti.prompt, string(ti.text))
+	}
 	cells := runewidth.StringWidth(string(ti.text[:ti.index]) + ti.prompt)
 	if cells != ti.cells && ti.focus {
 		ctx.SetCursor(cells, 0)
diff --git a/widgets/account-wizard.go b/widgets/account-wizard.go
new file mode 100644
index 0000000..3ce207c
--- /dev/null
+++ b/widgets/account-wizard.go
@@ -0,0 +1,625 @@
+package widgets
+
+import (
+	"net/url"
+	"strings"
+
+	"github.com/gdamore/tcell"
+
+	"git.sr.ht/~sircmpwn/aerc/lib/ui"
+)
+
+const (
+	CONFIGURE_BASICS   = iota
+	CONFIGURE_INCOMING = iota
+	CONFIGURE_OUTGOING = iota
+	CONFIGURE_COMPLETE = iota
+)
+
+const (
+	IMAP_OVER_TLS = iota
+	IMAP_STARTTLS = iota
+	IMAP_INSECURE = iota
+)
+
+const (
+	SMTP_OVER_TLS = iota
+	SMTP_STARTTLS = iota
+	SMTP_INSECURE = iota
+)
+
+type AccountWizard struct {
+	ui.Invalidatable
+	step    int
+	steps   []*ui.Grid
+	focus   int
+	testing bool
+	// CONFIGURE_BASICS
+	accountName *ui.TextInput
+	email       *ui.TextInput
+	fullName    *ui.TextInput
+	basics      []ui.Interactive
+	// CONFIGURE_INCOMING
+	imapUsername *ui.TextInput
+	imapPassword *ui.TextInput
+	imapServer   *ui.TextInput
+	imapMode     int
+	imapStr      *ui.Text
+	imapUrl      url.URL
+	incoming     []ui.Interactive
+	// CONFIGURE_OUTGOING
+	smtpUsername *ui.TextInput
+	smtpPassword *ui.TextInput
+	smtpServer   *ui.TextInput
+	smtpMode     int
+	smtpStr      *ui.Text
+	smtpUrl      url.URL
+	copySent     bool
+	outgoing     []ui.Interactive
+	// CONFIGURE_COMPLETE
+	complete []ui.Interactive
+}
+
+func NewAccountWizard() *AccountWizard {
+	wizard := &AccountWizard{
+		accountName:  ui.NewTextInput("").Prompt("> "),
+		email:        ui.NewTextInput("").Prompt("> "),
+		fullName:     ui.NewTextInput("").Prompt("> "),
+		imapUsername: ui.NewTextInput("").Prompt("> "),
+		imapPassword: ui.NewTextInput("").Prompt("] ").Password(true),
+		imapServer:   ui.NewTextInput("").Prompt("> "),
+		imapStr:      ui.NewText("imaps://"),
+		smtpUsername: ui.NewTextInput("").Prompt("> "),
+		smtpPassword: ui.NewTextInput("").Prompt("] ").Password(true),
+		smtpServer:   ui.NewTextInput("").Prompt("> "),
+		smtpStr:      ui.NewText("smtps://"),
+		copySent:     true,
+	}
+
+	// Autofill some stuff for the user
+	wizard.email.OnChange(func(_ *ui.TextInput) {
+		value := wizard.email.String()
+		wizard.imapUsername.Set(value)
+		wizard.smtpUsername.Set(value)
+		if strings.ContainsRune(value, '@') {
+			server := value[strings.IndexRune(value, '@')+1:]
+			wizard.imapServer.Set(server)
+			wizard.smtpServer.Set(server)
+		}
+		wizard.imapUri()
+		wizard.smtpUri()
+	})
+	wizard.imapServer.OnChange(func(_ *ui.TextInput) {
+		wizard.smtpServer.Set(wizard.imapServer.String())
+		wizard.imapUri()
+		wizard.smtpUri()
+	})
+	wizard.imapUsername.OnChange(func(_ *ui.TextInput) {
+		wizard.smtpUsername.Set(wizard.imapUsername.String())
+		wizard.imapUri()
+		wizard.smtpUri()
+	})
+	wizard.imapPassword.OnChange(func(_ *ui.TextInput) {
+		wizard.smtpPassword.Set(wizard.imapPassword.String())
+		wizard.imapUri()
+		wizard.smtpUri()
+	})
+	wizard.smtpServer.OnChange(func(_ *ui.TextInput) {
+		wizard.smtpUri()
+	})
+	wizard.smtpUsername.OnChange(func(_ *ui.TextInput) {
+		wizard.smtpUri()
+	})
+	wizard.smtpPassword.OnChange(func(_ *ui.TextInput) {
+		wizard.smtpUri()
+	})
+
+	basics := ui.NewGrid().Rows([]ui.GridSpec{
+		{ui.SIZE_EXACT, 8}, // Introduction
+		{ui.SIZE_EXACT, 1}, // Account name (label)
+		{ui.SIZE_EXACT, 1}, // (input)
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 1}, // Full name (label)
+		{ui.SIZE_EXACT, 1}, // (input)
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 1}, // Email address (label)
+		{ui.SIZE_EXACT, 1}, // (input)
+		{ui.SIZE_WEIGHT, 1},
+	}).Columns([]ui.GridSpec{
+		{ui.SIZE_WEIGHT, 1},
+	})
+	basics.AddChild(
+		ui.NewText("\nWelcome to aerc! Let's configure your account.\n\n" +
+			"This wizard supports basic IMAP & SMTP configuration.\n" +
+			"For other configurations, use <Ctrl+q> to exit and read the " +
+			"aerc-config(5) man page.\n" +
+			"Press <Tab> to cycle between each field in this form, or <Ctrl+k> and <Ctrl+j>."))
+	basics.AddChild(
+		ui.NewText("Name for this account? (e.g. 'Personal' or 'Work')").
+			Bold(true)).
+		At(1, 0)
+	basics.AddChild(wizard.accountName).
+		At(2, 0)
+	basics.AddChild(ui.NewFill(' ')).
+		At(3, 0)
+	basics.AddChild(
+		ui.NewText("Full name for outgoing emails? (e.g. 'John Doe')").
+			Bold(true)).
+		At(4, 0)
+	basics.AddChild(wizard.fullName).
+		At(5, 0)
+	basics.AddChild(ui.NewFill(' ')).
+		At(6, 0)
+	basics.AddChild(
+		ui.NewText("Your email address? (e.g. 'john@example.org')").Bold(true)).
+		At(7, 0)
+	basics.AddChild(wizard.email).
+		At(8, 0)
+	selecter := newSelecter([]string{"Next"}, 0).
+		OnChoose(wizard.advance)
+	basics.AddChild(selecter).At(9, 0)
+	wizard.basics = []ui.Interactive{
+		wizard.accountName, wizard.fullName, wizard.email, selecter,
+	}
+	basics.OnInvalidate(func(_ ui.Drawable) {
+		wizard.Invalidate()
+	})
+
+	incoming := ui.NewGrid().Rows([]ui.GridSpec{
+		{ui.SIZE_EXACT, 3}, // Introduction
+		{ui.SIZE_EXACT, 1}, // Username (label)
+		{ui.SIZE_EXACT, 1}, // (input)
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 1}, // Password (label)
+		{ui.SIZE_EXACT, 1}, // (input)
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 1}, // Server (label)
+		{ui.SIZE_EXACT, 1}, // (input)
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 1}, // Connection mode (label)
+		{ui.SIZE_EXACT, 2}, // (input)
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 2}, // Connection string
+		{ui.SIZE_WEIGHT, 1},
+	}).Columns([]ui.GridSpec{
+		{ui.SIZE_WEIGHT, 1},
+	})
+	incoming.AddChild(ui.NewText("\nConfigure incoming mail (IMAP)"))
+	incoming.AddChild(
+		ui.NewText("Username").Bold(true)).
+		At(1, 0)
+	incoming.AddChild(wizard.imapUsername).
+		At(2, 0)
+	incoming.AddChild(ui.NewFill(' ')).
+		At(3, 0)
+	incoming.AddChild(
+		ui.NewText("Password").Bold(true)).
+		At(4, 0)
+	incoming.AddChild(wizard.imapPassword).
+		At(5, 0)
+	incoming.AddChild(ui.NewFill(' ')).
+		At(6, 0)
+	incoming.AddChild(
+		ui.NewText("Server address "+
+			"(e.g. 'mail.example.org' or 'mail.example.org:1313')").Bold(true)).
+		At(7, 0)
+	incoming.AddChild(wizard.imapServer).
+		At(8, 0)
+	incoming.AddChild(ui.NewFill(' ')).
+		At(9, 0)
+	incoming.AddChild(
+		ui.NewText("Connection mode").Bold(true)).
+		At(10, 0)
+	imapMode := newSelecter([]string{
+		"IMAP over SSL/TLS",
+		"IMAP with STARTTLS",
+		"Insecure IMAP",
+	}, 0).Chooser(true).OnSelect(func(option string) {
+		switch option {
+		case "IMAP over SSL/TLS":
+			wizard.imapMode = IMAP_OVER_TLS
+		case "IMAP with STARTTLS":
+			wizard.imapMode = IMAP_STARTTLS
+		case "Insecure IMAP":
+			wizard.imapMode = IMAP_INSECURE
+		}
+		wizard.imapUri()
+	})
+	incoming.AddChild(imapMode).At(11, 0)
+	selecter = newSelecter([]string{"Previous", "Next"}, 1).
+		OnChoose(wizard.advance)
+	incoming.AddChild(ui.NewFill(' ')).At(12, 0)
+	incoming.AddChild(wizard.imapStr).At(13, 0)
+	incoming.AddChild(selecter).At(14, 0)
+	wizard.incoming = []ui.Interactive{
+		wizard.imapUsername, wizard.imapPassword, wizard.imapServer,
+		imapMode, selecter,
+	}
+	incoming.OnInvalidate(func(_ ui.Drawable) {
+		wizard.Invalidate()
+	})
+
+	outgoing := ui.NewGrid().Rows([]ui.GridSpec{
+		{ui.SIZE_EXACT, 3}, // Introduction
+		{ui.SIZE_EXACT, 1}, // Username (label)
+		{ui.SIZE_EXACT, 1}, // (input)
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 1}, // Password (label)
+		{ui.SIZE_EXACT, 1}, // (input)
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 1}, // Server (label)
+		{ui.SIZE_EXACT, 1}, // (input)
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 1}, // Connection mode (label)
+		{ui.SIZE_EXACT, 2}, // (input)
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 1}, // Connection string
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 1}, // Copy to sent (label)
+		{ui.SIZE_EXACT, 2}, // (input)
+		{ui.SIZE_WEIGHT, 1},
+	}).Columns([]ui.GridSpec{
+		{ui.SIZE_WEIGHT, 1},
+	})
+	outgoing.AddChild(ui.NewText("\nConfigure outgoing mail (SMTP)"))
+	outgoing.AddChild(
+		ui.NewText("Username").Bold(true)).
+		At(1, 0)
+	outgoing.AddChild(wizard.smtpUsername).
+		At(2, 0)
+	outgoing.AddChild(ui.NewFill(' ')).
+		At(3, 0)
+	outgoing.AddChild(
+		ui.NewText("Password").Bold(true)).
+		At(4, 0)
+	outgoing.AddChild(wizard.smtpPassword).
+		At(5, 0)
+	outgoing.AddChild(ui.NewFill(' ')).
+		At(6, 0)
+	outgoing.AddChild(
+		ui.NewText("Server address "+
+			"(e.g. 'mail.example.org' or 'mail.example.org:1313')").Bold(true)).
+		At(7, 0)
+	outgoing.AddChild(wizard.smtpServer).
+		At(8, 0)
+	outgoing.AddChild(ui.NewFill(' ')).
+		At(9, 0)
+	outgoing.AddChild(
+		ui.NewText("Connection mode").Bold(true)).
+		At(10, 0)
+	smtpMode := newSelecter([]string{
+		"SMTP over SSL/TLS",
+		"SMTP with STARTTLS",
+		"Insecure SMTP",
+	}, 0).Chooser(true).OnSelect(func(option string) {
+		switch option {
+		case "SMTP over SSL/TLS":
+			wizard.smtpMode = SMTP_OVER_TLS
+		case "SMTP with STARTTLS":
+			wizard.smtpMode = SMTP_STARTTLS
+		case "Insecure SMTP":
+			wizard.smtpMode = SMTP_INSECURE
+		}
+		wizard.smtpUri()
+	})
+	outgoing.AddChild(smtpMode).At(11, 0)
+	selecter = newSelecter([]string{"Previous", "Next"}, 1).
+		OnChoose(wizard.advance)
+	outgoing.AddChild(ui.NewFill(' ')).At(12, 0)
+	outgoing.AddChild(wizard.smtpStr).At(13, 0)
+	outgoing.AddChild(ui.NewFill(' ')).At(14, 0)
+	outgoing.AddChild(
+		ui.NewText("Copy sent messages to 'Sent' folder?").Bold(true)).
+		At(15, 0)
+	copySent := newSelecter([]string{"Yes", "No"}, 0).
+		Chooser(true).OnChoose(func(option string) {
+		switch option {
+		case "Yes":
+			wizard.copySent = true
+		case "No":
+			wizard.copySent = false
+		}
+	})
+	outgoing.AddChild(copySent).At(16, 0)
+	outgoing.AddChild(selecter).At(17, 0)
+	wizard.outgoing = []ui.Interactive{
+		wizard.smtpUsername, wizard.smtpPassword, wizard.smtpServer,
+		smtpMode, copySent, selecter,
+	}
+	outgoing.OnInvalidate(func(_ ui.Drawable) {
+		wizard.Invalidate()
+	})
+
+	complete := ui.NewGrid().Rows([]ui.GridSpec{
+		{ui.SIZE_EXACT, 7},  // Introduction
+		{ui.SIZE_WEIGHT, 1}, // Previous / Finish / Finish & open tutorial
+	}).Columns([]ui.GridSpec{
+		{ui.SIZE_WEIGHT, 1},
+	})
+	complete.AddChild(ui.NewText(
+		"\nConfiguration complete!\n\n" +
+			"You can go back and double check your settings, or choose 'Finish' to\n" +
+			"save your settings to accounts.conf.\n\n" +
+			"To add another account in the future, run ':new-account'."))
+	selecter = newSelecter([]string{
+		"Previous",
+		"Finish",
+		"Finish & open tutorial",
+	}, 1).OnChoose(func(option string) {
+		switch option {
+		case "Previous":
+			wizard.advance("Previous")
+		case "Finish & open tutorial":
+			// TODO
+			fallthrough
+		case "Finish":
+			// TODO
+		}
+	})
+	complete.AddChild(selecter).At(1, 0)
+	wizard.complete = []ui.Interactive{selecter}
+	complete.OnInvalidate(func(_ ui.Drawable) {
+		wizard.Invalidate()
+	})
+
+	wizard.steps = []*ui.Grid{basics, incoming, outgoing, complete}
+	return wizard
+}
+
+func (wizard *AccountWizard) imapUri() url.URL {
+	host := wizard.imapServer.String()
+	user := wizard.imapUsername.String()
+	pass := wizard.imapPassword.String()
+	var scheme string
+	switch wizard.imapMode {
+	case IMAP_OVER_TLS:
+		scheme = "imaps"
+	case IMAP_STARTTLS:
+		scheme = "imap"
+	case IMAP_INSECURE:
+		scheme = "imap+insecure"
+	}
+	var (
+		userpass   *url.Userinfo
+		userwopass *url.Userinfo
+	)
+	if pass == "" {
+		userpass = url.User(user)
+		userwopass = userpass
+	} else {
+		userpass = url.UserPassword(user, pass)
+		userwopass = url.UserPassword(user, strings.Repeat("*", len(pass)))
+	}
+	uri := url.URL{
+		Scheme: scheme,
+		Host:   host,
+		User:   userpass,
+	}
+	clean := url.URL{
+		Scheme: scheme,
+		Host:   host,
+		User:   userwopass,
+	}
+	wizard.imapStr.Text("Connection URL: " +
+		strings.ReplaceAll(clean.String(), "%2A", "*"))
+	wizard.imapUrl = uri
+	return uri
+}
+
+func (wizard *AccountWizard) smtpUri() url.URL {
+	host := wizard.smtpServer.String()
+	user := wizard.smtpUsername.String()
+	pass := wizard.smtpPassword.String()
+	var scheme string
+	switch wizard.smtpMode {
+	case SMTP_OVER_TLS:
+		scheme = "smtps+plain"
+	case SMTP_STARTTLS:
+		scheme = "smtp+plain"
+	case SMTP_INSECURE:
+		scheme = "smtp+plain"
+	}
+	var (
+		userpass   *url.Userinfo
+		userwopass *url.Userinfo
+	)
+	if pass == "" {
+		userpass = url.User(user)
+		userwopass = userpass
+	} else {
+		userpass = url.UserPassword(user, pass)
+		userwopass = url.UserPassword(user, strings.Repeat("*", len(pass)))
+	}
+	uri := url.URL{
+		Scheme: scheme,
+		Host:   host,
+		User:   userpass,
+	}
+	clean := url.URL{
+		Scheme: scheme,
+		Host:   host,
+		User:   userwopass,
+	}
+	wizard.smtpStr.Text("Connection URL: " +
+		strings.ReplaceAll(clean.String(), "%2A", "*"))
+	wizard.smtpUrl = uri
+	return uri
+}
+
+func (wizard *AccountWizard) Invalidate() {
+	wizard.DoInvalidate(wizard)
+}
+
+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_INCOMING:
+		return wizard.incoming
+	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()
+	switch event := event.(type) {
+	case *tcell.EventKey:
+		switch event.Key() {
+		case tcell.KeyUp:
+			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
+}
+
+type selecter struct {
+	ui.Invalidatable
+	chooser bool
+	focused bool
+	focus   int
+	options []string
+
+	onChoose func(option string)
+	onSelect func(option string)
+}
+
+func newSelecter(options []string, focus int) *selecter {
+	return &selecter{
+		focus:   focus,
+		options: options,
+	}
+}
+
+func (sel *selecter) Chooser(chooser bool) *selecter {
+	sel.chooser = chooser
+	return sel
+}
+
+func (sel *selecter) Invalidate() {
+	sel.DoInvalidate(sel)
+}
+
+func (sel *selecter) Draw(ctx *ui.Context) {
+	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
+	x := 2
+	for i, option := range sel.options {
+		style := tcell.StyleDefault
+		if sel.focus == i {
+			if sel.focused {
+				style = style.Reverse(true)
+			} else if sel.chooser {
+				style = style.Bold(true)
+			}
+		}
+		x += ctx.Printf(x, 1, style, "[%s]", option)
+		x += 5
+	}
+}
+
+func (sel *selecter) OnChoose(fn func(option string)) *selecter {
+	sel.onChoose = fn
+	return sel
+}
+
+func (sel *selecter) OnSelect(fn func(option string)) *selecter {
+	sel.onSelect = fn
+	return sel
+}
+
+func (sel *selecter) Selected() string {
+	return sel.options[sel.focus]
+}
+
+func (sel *selecter) Focus(focus bool) {
+	sel.focused = focus
+	sel.Invalidate()
+}
+
+func (sel *selecter) Event(event tcell.Event) bool {
+	switch event := event.(type) {
+	case *tcell.EventKey:
+		switch event.Key() {
+		case tcell.KeyCtrlH:
+			fallthrough
+		case tcell.KeyLeft:
+			if sel.focus > 0 {
+				sel.focus--
+				sel.Invalidate()
+			}
+			if sel.onSelect != nil {
+				sel.onSelect(sel.Selected())
+			}
+		case tcell.KeyCtrlL:
+			fallthrough
+		case tcell.KeyRight:
+			if sel.focus < len(sel.options)-1 {
+				sel.focus++
+				sel.Invalidate()
+			}
+			if sel.onSelect != nil {
+				sel.onSelect(sel.Selected())
+			}
+		case tcell.KeyEnter:
+			if sel.onChoose != nil {
+				sel.onChoose(sel.Selected())
+			}
+		}
+	}
+	return false
+}
diff --git a/widgets/aerc.go b/widgets/aerc.go
index 187eddb..eba76a2 100644
--- a/widgets/aerc.go
+++ b/widgets/aerc.go
@@ -99,6 +99,8 @@ func (aerc *Aerc) getBindings() *config.KeyBindings {
 	switch view := aerc.SelectedTab().(type) {
 	case *AccountView:
 		return aerc.conf.Bindings.MessageList
+	case *AccountWizard:
+		return aerc.conf.Bindings.AccountWizard
 	case *Composer:
 		switch view.Bindings() {
 		case "compose::editor":