Repository: mbrt/gmailctl
Pull request #205 (2021-11-01 18:07:31)

Deprecations
* Drop old config file formats.
* Add tool to migrate old config files to the newer format.


Patch:
diff --git a/README.md b/README.md
index 7bef222..1359d51 100644
--- a/README.md
+++ b/README.md
@@ -862,14 +862,43 @@ the other alias.
 
 ## Known issues
 
-* **Apply filters to existing emails**: gmailctl doesn't support this
-  functionality for security reasons. The project currently needs only very
-  basic permissisons, and applying filters to existing emails requires full
-  Gmail access. Bugs in gmailctl or in your configuration won't screw up your
-  old emails in any way, so this is an important safety feature. If you really
-  want to do this, you can manually export your rules with
-  `gmailctl export > filters.xml`, upload them by using the Gmail Settings UI
-  and select the "apply new filters to existing email" checkbox.
+### Apply filters to existing emails
+
+gmailctl doesn't support this functionality for security reasons. The project
+currently needs only very basic permissisons, and applying filters to existing
+emails requires full Gmail access. Bugs in gmailctl or in your configuration
+won't screw up your old emails in any way, so this is an important safety
+feature. If you really want to do this, you can manually export your rules with
+`gmailctl export > filters.xml`, upload them by using the Gmail Settings UI and
+select the "apply new filters to existing email" checkbox.
+
+### YAML config is unsupported
+
+gmailctl recently deprecated older config versions (`v1alpha1`, `v1alpha2`).
+There's however a migration tool to port those into the latest Jsonnet format.
+To convert your config:
+
+```bash
+$ go run github.com/mbrt/gmailctl/cmd/gmailctl-config-migrate \
+    ~/.gmailct/config.yaml > /tmp/gmailctl-config.jsonnet
+```
+
+**Note:** Adjust your paths if you're not keeping your config file in the
+default directory.
+
+Confirm that the new config file doesn't have errors, nor shows diffs with your
+remote filters.
+
+```bash
+$ gmailctl diff -f /tmp/gmailctl-config.jsonnet
+```
+
+If everything looks good, replace the old with the new config:
+
+```bash
+$ mv /tmp/gmailctl-config.jsonnet ~/.gmailctl/config.jsonnet
+$ rm ~/.gmailctl/config.yaml
+```
 
 ## Comparison with existing projects
 
diff --git a/cmd/gmailctl-config-migrate/import.go b/cmd/gmailctl-config-migrate/import.go
new file mode 100644
index 0000000..7149850
--- /dev/null
+++ b/cmd/gmailctl-config-migrate/import.go
@@ -0,0 +1,100 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"io/ioutil"
+
+	"gopkg.in/yaml.v2"
+
+	"github.com/mbrt/gmailctl/cmd/gmailctl-config-migrate/v1alpha1"
+	"github.com/mbrt/gmailctl/cmd/gmailctl-config-migrate/v1alpha2"
+	"github.com/mbrt/gmailctl/cmd/gmailctl-config-migrate/v1alpha3"
+	"github.com/mbrt/gmailctl/pkg/rimport"
+)
+
+const header = `// This file is generated by gmailctl-config-migrate.
+//
+// WARNING: This functionality is experimental. Before making any
+// changes, check that no diff is detected with the remote filters by
+// using the 'gmailctl diff' command.
+`
+
+func importConfig(path string, out io.Writer) error {
+	cfg, err := readConfig(path)
+	if err != nil {
+		return err
+	}
+	err = rimport.MarshalJsonnet(cfg, out, header)
+	if err != nil {
+		return fmt.Errorf("converting to Jsonnet: %w", err)
+	}
+	return nil
+}
+
+func readConfig(path string) (v1alpha3.Config, error) {
+	/* #nosec */
+	buf, err := ioutil.ReadFile(path)
+	if err != nil {
+		return v1alpha3.Config{}, err
+	}
+	return readYaml(buf)
+}
+
+func readYaml(buf []byte) (v1alpha3.Config, error) {
+	var res v1alpha3.Config
+	version, err := readYamlVersion(buf)
+	if err != nil {
+		return res, fmt.Errorf("parsing the config version: %w", err)
+	}
+
+	switch version {
+	case v1alpha3.Version:
+		var v3 v1alpha3.Config
+		err = yaml.UnmarshalStrict(buf, &v3)
+		if err != nil {
+			return res, fmt.Errorf("parsing the v1alpha3 config: %w", err)
+		}
+		return v3, nil
+
+	case v1alpha2.Version:
+		var v2 v1alpha2.Config
+		err = yaml.UnmarshalStrict(buf, &v2)
+		if err != nil {
+			return res, fmt.Errorf("parsing v1alpha2 config: %w", err)
+		}
+		return importFromV2(v2)
+
+	case v1alpha1.Version:
+		var v1 v1alpha1.Config
+		err = yaml.UnmarshalStrict(buf, &v1)
+		if err != nil {
+			return res, fmt.Errorf("parsing v1alpha1 config: %w", err)
+		}
+		return importFromV1(v1)
+
+	default:
+		return res, fmt.Errorf("unsupported config version: %s", version)
+	}
+}
+
+func importFromV1(v1 v1alpha1.Config) (v1alpha3.Config, error) {
+	v2, err := v1alpha2.Import(v1)
+	if err != nil {
+		return v1alpha3.Config{}, err
+	}
+	return importFromV2(v2)
+}
+
+func importFromV2(v2 v1alpha2.Config) (v1alpha3.Config, error) {
+	return v1alpha3.Import(v2)
+}
+
+func readYamlVersion(buf []byte) (string, error) {
+	// Try to unmarshal only the version
+	v := struct {
+		Version string `yaml:"version"`
+	}{}
+	err := yaml.Unmarshal(buf, &v)
+	return v.Version, err
+}
diff --git a/cmd/gmailctl-config-migrate/main.go b/cmd/gmailctl-config-migrate/main.go
new file mode 100644
index 0000000..2a02106
--- /dev/null
+++ b/cmd/gmailctl-config-migrate/main.go
@@ -0,0 +1,27 @@
+// gmailctl-config-migrate is a tool to migrate gmailctl YAML configs to the latest version.
+package main
+
+import (
+	"flag"
+	"fmt"
+	"os"
+)
+
+func do(path string) error {
+	return importConfig(path, os.Stdout)
+}
+
+func main() {
+	flag.Parse()
+
+	if len(flag.Args()) < 1 {
+		fmt.Fprintln(os.Stderr, "No config file specified")
+		os.Exit(1)
+	}
+	for _, path := range flag.Args() {
+		if err := do(path); err != nil {
+			fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+			os.Exit(1)
+		}
+	}
+}
diff --git a/pkg/config/v1alpha1/config.go b/cmd/gmailctl-config-migrate/v1alpha1/config.go
similarity index 100%
rename from pkg/config/v1alpha1/config.go
rename to cmd/gmailctl-config-migrate/v1alpha1/config.go
diff --git a/pkg/config/v1alpha1/config_test.go b/cmd/gmailctl-config-migrate/v1alpha1/config_test.go
similarity index 100%
rename from pkg/config/v1alpha1/config_test.go
rename to cmd/gmailctl-config-migrate/v1alpha1/config_test.go
diff --git a/pkg/config/v1alpha1/resolve.go b/cmd/gmailctl-config-migrate/v1alpha1/resolve.go
similarity index 100%
rename from pkg/config/v1alpha1/resolve.go
rename to cmd/gmailctl-config-migrate/v1alpha1/resolve.go
diff --git a/pkg/config/v1alpha2/config.go b/cmd/gmailctl-config-migrate/v1alpha2/config.go
similarity index 97%
rename from pkg/config/v1alpha2/config.go
rename to cmd/gmailctl-config-migrate/v1alpha2/config.go
index 8aa655f..2f1fa02 100644
--- a/pkg/config/v1alpha2/config.go
+++ b/cmd/gmailctl-config-migrate/v1alpha2/config.go
@@ -5,7 +5,7 @@ import (
 	"reflect"
 	"strings"
 
-	v1 "github.com/mbrt/gmailctl/pkg/config/v1alpha1"
+	"github.com/mbrt/gmailctl/cmd/gmailctl-config-migrate/v1alpha1"
 	"github.com/mbrt/gmailctl/pkg/gmail"
 )
 
@@ -112,7 +112,7 @@ func (r Rule) String() string {
 }
 
 // Author represents the owner of the gmail account.
-type Author v1.Author
+type Author v1alpha1.Author
 
 // Actions contains the actions to be applied to a set of emails.
 type Actions struct {
diff --git a/pkg/config/v1alpha2/import.go b/cmd/gmailctl-config-migrate/v1alpha2/import.go
similarity index 90%
rename from pkg/config/v1alpha2/import.go
rename to cmd/gmailctl-config-migrate/v1alpha2/import.go
index 8cbc0fa..3ea8e31 100644
--- a/pkg/config/v1alpha2/import.go
+++ b/cmd/gmailctl-config-migrate/v1alpha2/import.go
@@ -3,12 +3,12 @@ package v1alpha2
 import (
 	"fmt"
 
-	v1 "github.com/mbrt/gmailctl/pkg/config/v1alpha1"
+	"github.com/mbrt/gmailctl/cmd/gmailctl-config-migrate/v1alpha1"
 )
 
 // Import converts a v1 config into a v2.
-func Import(cfg v1.Config) (Config, error) {
-	cfg, err := v1.ResolveConsts(cfg)
+func Import(cfg v1alpha1.Config) (Config, error) {
+	cfg, err := v1alpha1.ResolveConsts(cfg)
 	if err != nil {
 		return Config{}, fmt.Errorf("resolving consts: %w", err)
 	}
@@ -25,7 +25,7 @@ func Import(cfg v1.Config) (Config, error) {
 	}, nil
 }
 
-func convertRule(r v1.Rule) Rule {
+func convertRule(r v1alpha1.Rule) Rule {
 	// Optional parameters need to be set only if the original action was
 	// explicitly set.
 	onlyIfSet := func(a bool) *bool {
@@ -50,7 +50,7 @@ func convertRule(r v1.Rule) Rule {
 	}
 }
 
-func convertFilter(f v1.Filters) FilterNode {
+func convertFilter(f v1alpha1.Filters) FilterNode {
 	// All the filters at this level are in 'and'
 	res := convertMatchFilter(f.CompositeFilters.MatchFilters)
 	if op := convertMatchFilter(f.Not); !op.Empty() {
@@ -61,7 +61,7 @@ func convertFilter(f v1.Filters) FilterNode {
 	return and(res, FilterNode{Query: f.Query})
 }
 
-func convertMatchFilter(f v1.MatchFilters) FilterNode {
+func convertMatchFilter(f v1alpha1.MatchFilters) FilterNode {
 	// This filter is an 'and' of operators, where each of them is an 'or'.
 	var res FilterNode
 
diff --git a/pkg/config/v1alpha2/testdata/empty.v1.yaml b/cmd/gmailctl-config-migrate/v1alpha2/testdata/empty.v1.yaml
similarity index 100%
rename from pkg/config/v1alpha2/testdata/empty.v1.yaml
rename to cmd/gmailctl-config-migrate/v1alpha2/testdata/empty.v1.yaml
diff --git a/pkg/config/v1alpha2/testdata/empty.v2.yaml b/cmd/gmailctl-config-migrate/v1alpha2/testdata/empty.v2.yaml
similarity index 100%
rename from pkg/config/v1alpha2/testdata/empty.v2.yaml
rename to cmd/gmailctl-config-migrate/v1alpha2/testdata/empty.v2.yaml
diff --git a/pkg/config/v1alpha2/testdata/example.v1.yaml b/cmd/gmailctl-config-migrate/v1alpha2/testdata/example.v1.yaml
similarity index 100%
rename from pkg/config/v1alpha2/testdata/example.v1.yaml
rename to cmd/gmailctl-config-migrate/v1alpha2/testdata/example.v1.yaml
diff --git a/pkg/config/v1alpha2/testdata/example.v2.yaml b/cmd/gmailctl-config-migrate/v1alpha2/testdata/example.v2.yaml
similarity index 100%
rename from pkg/config/v1alpha2/testdata/example.v2.yaml
rename to cmd/gmailctl-config-migrate/v1alpha2/testdata/example.v2.yaml
diff --git a/pkg/config/v1alpha2/testdata/preserved_author.v1.yaml b/cmd/gmailctl-config-migrate/v1alpha2/testdata/preserved_author.v1.yaml
similarity index 100%
rename from pkg/config/v1alpha2/testdata/preserved_author.v1.yaml
rename to cmd/gmailctl-config-migrate/v1alpha2/testdata/preserved_author.v1.yaml
diff --git a/pkg/config/v1alpha2/testdata/preserved_author.v2.yaml b/cmd/gmailctl-config-migrate/v1alpha2/testdata/preserved_author.v2.yaml
similarity index 100%
rename from pkg/config/v1alpha2/testdata/preserved_author.v2.yaml
rename to cmd/gmailctl-config-migrate/v1alpha2/testdata/preserved_author.v2.yaml
diff --git a/cmd/gmailctl-config-migrate/v1alpha3/alias.go b/cmd/gmailctl-config-migrate/v1alpha3/alias.go
new file mode 100644
index 0000000..313f43f
--- /dev/null
+++ b/cmd/gmailctl-config-migrate/v1alpha3/alias.go
@@ -0,0 +1,9 @@
+package v1alpha3
+
+import cfg "github.com/mbrt/gmailctl/pkg/config/v1alpha3"
+
+// Config is an alias pointing to the actual v1alpha3.Config type.
+type Config = cfg.Config
+
+// Version is an alias pointing to the actual v1alpha3.Version type.
+const Version = cfg.Version
diff --git a/pkg/config/v1alpha3/import.go b/cmd/gmailctl-config-migrate/v1alpha3/import.go
similarity index 64%
rename from pkg/config/v1alpha3/import.go
rename to cmd/gmailctl-config-migrate/v1alpha3/import.go
index a53d224..3f29fa4 100644
--- a/pkg/config/v1alpha3/import.go
+++ b/cmd/gmailctl-config-migrate/v1alpha3/import.go
@@ -5,13 +5,14 @@ import (
 
 	"github.com/hashicorp/go-multierror"
 
-	v2 "github.com/mbrt/gmailctl/pkg/config/v1alpha2"
+	"github.com/mbrt/gmailctl/cmd/gmailctl-config-migrate/v1alpha2"
+	"github.com/mbrt/gmailctl/pkg/config/v1alpha3"
 )
 
-var dummyFilter = FilterNode{}
+var dummyFilter = v1alpha3.FilterNode{}
 
 // Import converts a v2 config into a v3.
-func Import(cfg v2.Config) (Config, error) {
+func Import(cfg v1alpha2.Config) (v1alpha3.Config, error) {
 	i := importer{}
 	return i.Import(cfg)
 }
@@ -21,11 +22,11 @@ type importer struct {
 	err  error
 }
 
-func (i *importer) Import(cfg v2.Config) (Config, error) {
+func (i *importer) Import(cfg v1alpha2.Config) (v1alpha3.Config, error) {
 	i.importNamedFilters(cfg.Filters)
 	finalErr := i.resetError()
 
-	var rules []Rule
+	var rules []v1alpha3.Rule
 	for _, r := range cfg.Rules {
 		rules = append(rules, i.importRule(r))
 		if err := i.resetError(); err != nil {
@@ -35,14 +36,17 @@ func (i *importer) Import(cfg v2.Config) (Config, error) {
 		}
 	}
 
-	return Config{
-		Version: Version,
-		Author:  cfg.Author,
-		Rules:   rules,
+	return v1alpha3.Config{
+		Version: v1alpha3.Version,
+		Author: v1alpha3.Author{
+			Name:  cfg.Author.Name,
+			Email: cfg.Author.Email,
+		},
+		Rules: rules,
 	}, finalErr
 }
 
-func (i *importer) importNamedFilters(fs []v2.NamedFilter) {
+func (i *importer) importNamedFilters(fs []v1alpha2.NamedFilter) {
 	i.nmap = namedFilterMap{}
 	var finalErr error
 
@@ -58,10 +62,10 @@ func (i *importer) importNamedFilters(fs []v2.NamedFilter) {
 	i.err = finalErr
 }
 
-func (i *importer) importRule(r v2.Rule) Rule {
-	return Rule{
+func (i *importer) importRule(r v1alpha2.Rule) v1alpha3.Rule {
+	return v1alpha3.Rule{
 		Filter: i.importFilter(r.Filter),
-		Actions: Actions{
+		Actions: v1alpha3.Actions{
 			Archive:       r.Actions.Archive,
 			Delete:        r.Actions.Delete,
 			MarkRead:      r.Actions.MarkRead,
@@ -74,17 +78,17 @@ func (i *importer) importRule(r v2.Rule) Rule {
 	}
 }
 
-func (i *importer) importFilter(f v2.FilterNode) FilterNode {
+func (i *importer) importFilter(f v1alpha2.FilterNode) v1alpha3.FilterNode {
 	if f.RefName != "" {
 		return i.importRefName(f.RefName)
 	}
 
-	var not *FilterNode
+	var not *v1alpha3.FilterNode
 	if f.Not != nil {
 		nf := i.importFilter(*f.Not)
 		not = &nf
 	}
-	return FilterNode{
+	return v1alpha3.FilterNode{
 		And:     i.importFilters(f.And),
 		Or:      i.importFilters(f.Or),
 		Not:     not,
@@ -98,15 +102,15 @@ func (i *importer) importFilter(f v2.FilterNode) FilterNode {
 	}
 }
 
-func (i *importer) importFilters(ns []v2.FilterNode) []FilterNode {
-	var res []FilterNode
+func (i *importer) importFilters(ns []v1alpha2.FilterNode) []v1alpha3.FilterNode {
+	var res []v1alpha3.FilterNode
 	for _, f := range ns {
 		res = append(res, i.importFilter(f))
 	}
 	return res
 }
 
-func (i *importer) importRefName(name string) FilterNode {
+func (i *importer) importRefName(name string) v1alpha3.FilterNode {
 	if n, ok := i.nmap[name]; ok {
 		return n
 	}
@@ -121,4 +125,4 @@ func (i *importer) resetError() error {
 	return err
 }
 
-type namedFilterMap map[string]FilterNode
+type namedFilterMap map[string]v1alpha3.FilterNode
diff --git a/pkg/config/v1alpha3/testdata/example.v2.yaml b/cmd/gmailctl-config-migrate/v1alpha3/testdata/example.v2.yaml
similarity index 100%
rename from pkg/config/v1alpha3/testdata/example.v2.yaml
rename to cmd/gmailctl-config-migrate/v1alpha3/testdata/example.v2.yaml
diff --git a/pkg/config/v1alpha3/testdata/example.v3.json b/cmd/gmailctl-config-migrate/v1alpha3/testdata/example.v3.json
similarity index 100%
rename from pkg/config/v1alpha3/testdata/example.v3.json
rename to cmd/gmailctl-config-migrate/v1alpha3/testdata/example.v3.json
diff --git a/cmd/gmailctl/cmd/apply_cmd.go b/cmd/gmailctl/cmd/apply_cmd.go
index 1492f8a..0595558 100644
--- a/cmd/gmailctl/cmd/apply_cmd.go
+++ b/cmd/gmailctl/cmd/apply_cmd.go
@@ -30,7 +30,7 @@ var applyCmd = &cobra.Command{
 to make them match your local configuration file.
 
 By default apply uses the configuration file inside the config
-directory [config.(yaml|jsonnet)].`,
+directory [config.jsonnet].`,
 	Run: func(cmd *cobra.Command, args []string) {
 		f := applyFilename
 		if f == "" {
diff --git a/cmd/gmailctl/cmd/config.go b/cmd/gmailctl/cmd/config.go
index ef804ac..0e2e821 100644
--- a/cmd/gmailctl/cmd/config.go
+++ b/cmd/gmailctl/cmd/config.go
@@ -9,11 +9,11 @@ import (
 	papply "github.com/mbrt/gmailctl/pkg/apply"
 	"github.com/mbrt/gmailctl/pkg/cfgtest"
 	"github.com/mbrt/gmailctl/pkg/config"
-	cfgv3 "github.com/mbrt/gmailctl/pkg/config/v1alpha3"
+	"github.com/mbrt/gmailctl/pkg/config/v1alpha3"
 )
 
 type parseResult struct {
-	Config cfgv3.Config
+	Config v1alpha3.Config
 	Res    papply.ConfigParseRes
 }
 
diff --git a/cmd/gmailctl/cmd/data.go b/cmd/gmailctl/cmd/data.go
index d9370fb..386785d 100644
--- a/cmd/gmailctl/cmd/data.go
+++ b/cmd/gmailctl/cmd/data.go
@@ -2,12 +2,12 @@
 
 package cmd
 
-// gmailctlLib returns the embedded gmailctl.libsonnet file
-func gmailctlLib() string {
+// GmailctlLib returns the embedded gmailctl.libsonnet file
+func GmailctlLib() string {
 	return string(MustAsset("../../../gmailctl.libsonnet"))
 }
 
-// defaultConfig returns the embedded default configuration file
-func defaultConfig() string {
+// DefaultConfig returns the embedded default configuration file
+func DefaultConfig() string {
 	return string(MustAsset("../../../default-config.jsonnet"))
 }
diff --git a/cmd/gmailctl/cmd/debug_cmd.go b/cmd/gmailctl/cmd/debug_cmd.go
index 38a2141..25a0a13 100644
--- a/cmd/gmailctl/cmd/debug_cmd.go
+++ b/cmd/gmailctl/cmd/debug_cmd.go
@@ -23,7 +23,7 @@ with handy URLs to Gmail search that can be used to test that the
 filter applies to the intended emails.
 
 By default debug uses the configuration file inside the config
-directory [config.(yaml|jsonnet)].`,
+directory config.jsonnet].`,
 	Run: func(cmd *cobra.Command, args []string) {
 		f := debugFilename
 		if f == "" {
diff --git a/cmd/gmailctl/cmd/diff_cmd.go b/cmd/gmailctl/cmd/diff_cmd.go
index 355daad..40dc3cf 100644
--- a/cmd/gmailctl/cmd/diff_cmd.go
+++ b/cmd/gmailctl/cmd/diff_cmd.go
@@ -20,7 +20,7 @@ var diffCmd = &cobra.Command{
 configuration and the current Gmail settings of your account.
 
 By default diff uses the configuration file inside the config
-directory [config.(yaml|jsonnet)].`,
+directory [config.jsonnet].`,
 	Run: func(cmd *cobra.Command, args []string) {
 		f := diffFilename
 		if f == "" {
diff --git a/cmd/gmailctl/cmd/download_cmd.go b/cmd/gmailctl/cmd/download_cmd.go
index 0f62a27..d0dec4f 100644
--- a/cmd/gmailctl/cmd/download_cmd.go
+++ b/cmd/gmailctl/cmd/download_cmd.go
@@ -1,23 +1,15 @@
 package cmd
 
 import (
-	"bufio"
-	"bytes"
-	"encoding/json"
 	"fmt"
 	"io"
 	"os"
-	"regexp"
 
 	"github.com/spf13/cobra"
 
 	"github.com/mbrt/gmailctl/pkg/rimport"
 )
 
-var (
-	downloadOutput string
-)
-
 const downloadHeader = `// Auto-imported filters by 'gmailctl download'.
 //
 // WARNING: This functionality is experimental. Before making any
@@ -28,12 +20,9 @@ const downloadHeader = `// Auto-imported filters by 'gmailctl download'.
 // local lib = import 'gmailctl.libsonnet';
 `
 
-const labelsComment = `  // Note: labels management is optional. If you prefer to use the
-  // GMail interface to add and remove labels, you can safely remove
-  // this section of the config.
-`
-
-var labelsLine = "  labels: ["
+var (
+	downloadOutput string
+)
 
 // downloadCmd represents the import command
 var downloadCmd = &cobra.Command{
@@ -100,68 +89,9 @@ func downloadWithOut(out io.Writer) error {
 		return err
 	}
 
-	err = marshalJsonnet(cfg, out)
+	err = rimport.MarshalJsonnet(cfg, out, downloadHeader)
 	if err != nil {
 		return fmt.Errorf("converting to Jsonnet: %w", err)
 	}
 	return nil
 }
-
-func marshalJsonnet(v interface{}, w io.Writer) error {
-	// Convert to JSON
-	b, err := json.MarshalIndent(v, "", "  ")
-	if err != nil {
-		return err
-	}
-
-	// Make JSON resemble Jsonnet by getting rid of unnecessary quotes
-	reader := bufio.NewReader(bytes.NewReader(b))
-	writer := bufio.NewWriter(w)
-	keyRe := regexp.MustCompile(`^ *"([a-zA-Z01]+)":`)
-	var line []byte
-
-	_, err = writer.WriteString(downloadHeader)
-	if err != nil {
-		return err
-	}
-
-	line, _, err = reader.ReadLine()
-	for err == nil {
-		line = replaceGroupsRe(keyRe, line)
-		if string(line) == labelsLine {
-			_, err = writer.WriteString(labelsComment)
-			if err != nil {
-				break
-			}
-		}
-		_, err = writer.Write(line)
-		if err != nil {
-			break
-		}
-		_, err = writer.WriteRune('\n')
-		if err != nil {
-			break
-		}
-
-		line, _, err = reader.ReadLine()
-	}
-
-	if err == io.EOF {
-		return writer.Flush()
-	}
-	return err
-}
-
-func replaceGroupsRe(re *regexp.Regexp, in []byte) []byte {
-	m := re.FindSubmatchIndex(in)
-	if len(m) == 0 {
-		return in
-	}
-	keyb, keye := m[2], m[3]
-
-	var res []byte
-	res = append(res, in[:keyb-1]...)
-	res = append(res, in[keyb:keye]...)
-	res = append(res, in[keye+1:]...)
-	return res
-}
diff --git a/cmd/gmailctl/cmd/edit_cmd.go b/cmd/gmailctl/cmd/edit_cmd.go
index 2655a73..cd6666c 100644
--- a/cmd/gmailctl/cmd/edit_cmd.go
+++ b/cmd/gmailctl/cmd/edit_cmd.go
@@ -51,7 +51,7 @@ The editor to be used can be overridden with the $EDITOR
 environment variable.
 
 By default edit uses the configuration file inside the config
-directory [config.(yaml|jsonnet)].`,
+directory [config.jsonnet].`,
 	Run: func(cmd *cobra.Command, args []string) {
 		f := editFilename
 		if f == "" {
@@ -143,7 +143,7 @@ func copyToTmp(path string) (string, error) {
 		return "", errors.WithCause(err, config.ErrNotFound)
 	}
 
-	// Use the same extension as the original file (yaml | jsonnet)
+	// Use the same extension as the original file.
 	tmp, err := ioutil.TempFile("", fmt.Sprintf("gmailctl-*%s", filepath.Ext(path)))
 	if err != nil {
 		return "", fmt.Errorf("creating tmp file: %w", err)
diff --git a/cmd/gmailctl/cmd/export_cmd.go b/cmd/gmailctl/cmd/export_cmd.go
index d519231..1181a55 100644
--- a/cmd/gmailctl/cmd/export_cmd.go
+++ b/cmd/gmailctl/cmd/export_cmd.go
@@ -25,7 +25,7 @@ This allows to import them from within the Gmail settings or to share
 them with other people.
 
 By default export uses the configuration file inside the config
-directory [config.(yaml|jsonnet)].`,
+directory [config.jsonnet].`,
 	Run: func(cmd *cobra.Command, args []string) {
 		f := exportFilename
 		if f == "" {
diff --git a/cmd/gmailctl/cmd/init_cmd.go b/cmd/gmailctl/cmd/init_cmd.go
index e30186c..bf23be1 100644
--- a/cmd/gmailctl/cmd/init_cmd.go
+++ b/cmd/gmailctl/cmd/init_cmd.go
@@ -126,24 +126,13 @@ func handleCfgDir() (err error) {
 		}
 	}
 
-	// Handle legacy yaml configuration
-	if hasYamlConfig(cfgDir) {
-		return nil
-	}
-
 	// Create default config files
 	cfgFile := path.Join(cfgDir, "config.jsonnet")
-	if err := createDefault(cfgFile, defaultConfig()); err != nil {
+	if err := createDefault(cfgFile, DefaultConfig()); err != nil {
 		return err
 	}
 	libFile := path.Join(cfgDir, "gmailctl.libsonnet")
-	return createDefault(libFile, gmailctlLib())
-}
-
-func hasYamlConfig(cfgDir string) bool {
-	path := path.Join(cfgDir, "config.yaml")
-	_, err := os.Stat(path)
-	return err == nil
+	return createDefault(libFile, GmailctlLib())
 }
 
 func createDefault(path, contents string) (err error) {
diff --git a/hack/coverage.sh b/hack/coverage.sh
new file mode 100755
index 0000000..08f2a4a
--- /dev/null
+++ b/hack/coverage.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+
+set -e
+
+COV=$(mktemp --suffix=.cov)
+HTML=$(mktemp --suffix=.html)
+
+go test -v -coverpkg=./... -coverprofile="${COV}" ./...
+go tool cover -func="${COV}"
+go tool cover -html="${COV}" -o "${HTML}"
+
+echo "Find results in ${HTML}"
diff --git a/internal/fakegmail/fakegmail.go b/internal/fakegmail/fakegmail.go
index c2b930f..bef4a01 100644
--- a/internal/fakegmail/fakegmail.go
+++ b/internal/fakegmail/fakegmail.go
@@ -245,7 +245,7 @@ func (g *gmail) DeleteLabel(id string) error {
 	if !ok {
 		return statusError{404, fmt.Errorf("id %q not found", id)}
 	}
-	delete(g.labelNames, l.Name)
+	g.labelNames.Remove(l.Name)
 	delete(g.labels, id)
 
 	return nil
diff --git a/pkg/apply/apply.go b/pkg/apply/apply.go
index 704a44d..c725990 100644
--- a/pkg/apply/apply.go
+++ b/pkg/apply/apply.go
@@ -5,7 +5,7 @@ import (
 	"sort"
 	"strings"
 
-	cfgv3 "github.com/mbrt/gmailctl/pkg/config/v1alpha3"
+	"github.com/mbrt/gmailctl/pkg/config/v1alpha3"
 	"github.com/mbrt/gmailctl/pkg/filter"
 	"github.com/mbrt/gmailctl/pkg/label"
 	"github.com/mbrt/gmailctl/pkg/parser"
@@ -24,7 +24,7 @@ type ConfigParseRes struct {
 }
 
 // FromConfig creates a GmailConfig from a parsed configuration file.
-func FromConfig(cfg cfgv3.Config) (ConfigParseRes, error) {
+func FromConfig(cfg v1alpha3.Config) (ConfigParseRes, error) {
 	res := ConfigParseRes{}
 	var err error
 
diff --git a/pkg/config/read.go b/pkg/config/read.go
index 968a1a4..1714ee9 100644
--- a/pkg/config/read.go
+++ b/pkg/config/read.go
@@ -9,16 +9,17 @@ import (
 	"path/filepath"
 
 	"github.com/google/go-jsonnet"
-	"gopkg.in/yaml.v2"
 
-	cfgv1 "github.com/mbrt/gmailctl/pkg/config/v1alpha1"
-	cfgv2 "github.com/mbrt/gmailctl/pkg/config/v1alpha2"
-	cfgv3 "github.com/mbrt/gmailctl/pkg/config/v1alpha3"
+	"github.com/mbrt/gmailctl/pkg/config/v1alpha3"
 	"github.com/mbrt/gmailctl/pkg/errors"
 )
 
-// LatestVersion points to the latest version of the config format.
-const LatestVersion = cfgv3.Version
+const (
+	// LatestVersion points to the latest version of the config format.
+	LatestVersion = v1alpha3.Version
+
+	unsupportedHelp = "Please see https://github.com/mbrt/gmailctl#yaml-config-is-unsupported.\n"
+)
 
 // ErrNotFound is returned when a file was not found.
 var ErrNotFound = errors.New("config not found")
@@ -27,26 +28,30 @@ var ErrNotFound = errors.New("config not found")
 //
 // If the config file needs to have access to additional libraries,
 // their location can be specified with cfgDirs.
-func ReadFile(path, libPath string) (cfgv3.Config, error) {
+func ReadFile(path, libPath string) (v1alpha3.Config, error) {
 	/* #nosec */
 	b, err := ioutil.ReadFile(path)
 	if err != nil {
-		return cfgv3.Config{}, errors.WithCause(err, ErrNotFound)
+		return v1alpha3.Config{}, errors.WithCause(err, ErrNotFound)
 	}
-	if filepath.Ext(path) == ".jsonnet" {
-		// We pass the libPath to jsonnet, because that is the hint
-		// to the libraries location. If no library is specified,
-		// we use the original file location.
-		if libPath != "" {
-			return readJsonnet(libPath, b)
-		}
-		return readJsonnet(path, b)
+	if ext := filepath.Ext(path); ext == ".yml" || ext == ".yaml" {
+		return v1alpha3.Config{}, errors.WithDetails(errors.New("YAML config is unsupported"),
+			unsupportedHelp)
 	}
-	return readYaml(b)
+	// We pass the libPath to jsonnet, because that is the hint
+	// to the libraries location. If no library is specified,
+	// we use the original file location.
+	if libPath == "" {
+		libPath = path
+	}
+	return ReadJsonnet(libPath, b)
 }
 
-func readJsonnet(p string, buf []byte) (cfgv3.Config, error) {
-	var res cfgv3.Config
+// ReadJsonnet parses a buffer containing a jsonnet config.
+//
+// The path is used to resolve imports.
+func ReadJsonnet(p string, buf []byte) (v1alpha3.Config, error) {
+	var res v1alpha3.Config
 	vm := jsonnet.MakeVM()
 	vm.Importer(&jsonnet.FileImporter{
 		JPaths: []string{path.Dir(p)},
@@ -59,86 +64,12 @@ func readJsonnet(p string, buf []byte) (cfgv3.Config, error) {
 	if err != nil {
 		return res, fmt.Errorf("parsing the config version: %w", err)
 	}
-
-	switch version {
-	case cfgv3.Version:
-		err = jsonUnmarshalStrict([]byte(jstr), &res)
-		return res, err
-
-	case cfgv2.Version:
-		var v2 cfgv2.Config
-		err = jsonUnmarshalStrict([]byte(jstr), &v2)
-		if err != nil {
-			return res, fmt.Errorf("parsing v1alpha2 config: %w", err)
-		}
-		return importFromV2(v2)
-
-	case cfgv1.Version:
-		var v1 cfgv1.Config
-		err = jsonUnmarshalStrict([]byte(jstr), &v1)
-		if err != nil {
-			return res, fmt.Errorf("parsing v1alpha1 config: %w", err)
-		}
-		return importFromV1(v1)
-
-	default:
-		return res, fmt.Errorf("unknown config version: %s", version)
+	if version != LatestVersion {
+		return res, errors.WithDetails(fmt.Errorf("unsupported config version: %s", version),
+			unsupportedHelp)
 	}
-}
-
-func readYaml(buf []byte) (cfgv3.Config, error) {
-	// TODO: Get rid of support for YAML config v3
-	var res cfgv3.Config
-	version, err := readYamlVersion(buf)
-	if err != nil {
-		return res, fmt.Errorf("parsing the config version: %w", err)
-	}
-
-	switch version {
-	case cfgv3.Version:
-		err = yaml.UnmarshalStrict(buf, &res)
-		return res, err
-
-	case cfgv2.Version:
-		var v2 cfgv2.Config
-		err = yaml.UnmarshalStrict(buf, &v2)
-		if err != nil {
-			return res, fmt.Errorf("parsing v1alpha2 config: %w", err)
-		}
-		return importFromV2(v2)
-
-	case cfgv1.Version:
-		var v1 cfgv1.Config
-		err = yaml.UnmarshalStrict(buf, &v1)
-		if err != nil {
-			return res, fmt.Errorf("parsing v1alpha1 config: %w", err)
-		}
-		return importFromV1(v1)
-
-	default:
-		return res, fmt.Errorf("unknown config version: %s", version)
-	}
-}
-
-func importFromV1(v1 cfgv1.Config) (cfgv3.Config, error) {
-	v2, err := cfgv2.Import(v1)
-	if err != nil {
-		return cfgv3.Config{}, err
-	}
-	return importFromV2(v2)
-}
-
-func importFromV2(v2 cfgv2.Config) (cfgv3.Config, error) {
-	return cfgv3.Import(v2)
-}
-
-func readYamlVersion(buf []byte) (string, error) {
-	// Try to unmarshal only the version
-	v := struct {
-		Version string `yaml:"version"`
-	}{}
-	err := yaml.Unmarshal(buf, &v)
-	return v.Version, err
+	err = jsonUnmarshalStrict([]byte(jstr), &res)
+	return res, err
 }
 
 func readJSONVersion(js string) (string, error) {
diff --git a/pkg/config/v1alpha3/config.go b/pkg/config/v1alpha3/config.go
index 76dc3f9..5691fd1 100644
--- a/pkg/config/v1alpha3/config.go
+++ b/pkg/config/v1alpha3/config.go
@@ -4,14 +4,13 @@ import (
 	"reflect"
 	"strings"
 
-	v2 "github.com/mbrt/gmailctl/pkg/config/v1alpha2"
 	"github.com/mbrt/gmailctl/pkg/gmail"
 )
 
 // Version is the latest supported version.
 const Version = "v1alpha3"
 
-// Config contains the yaml configuration of the Gmail filters.
+// Config contains the Jsonnet configuration of the Gmail filters.
 type Config struct {
 	Version string  `json:"version"`
 	Author  Author  `json:"author,omitempty"`
@@ -94,7 +93,10 @@ type Rule struct {
 }
 
 // Author represents the owner of the gmail account.
-type Author = v2.Author
+type Author struct {
+	Name  string `json:"name"`
+	Email string `json:"email"`
+}
 
 // Actions contains the actions to be applied to a set of emails.
 type Actions struct {
diff --git a/pkg/export/xml/marshal.go b/pkg/export/xml/marshal.go
index 85f11d2..d7d1b86 100644
--- a/pkg/export/xml/marshal.go
+++ b/pkg/export/xml/marshal.go
@@ -5,7 +5,7 @@ import (
 	"io"
 	"time"
 
-	cfgv2 "github.com/mbrt/gmailctl/pkg/config/v1alpha2"
+	"github.com/mbrt/gmailctl/pkg/config/v1alpha3"
 	"github.com/mbrt/gmailctl/pkg/filter"
 )
 
@@ -58,7 +58,7 @@ type Exporter struct {
 }
 
 // Export exports Gmail filters into the Gmail xml format.
-func (x Exporter) Export(author cfgv2.Author, filters filter.Filters, w io.Writer) error {
+func (x Exporter) Export(author v1alpha3.Author, filters filter.Filters, w io.Writer) error {
 	doc, err := x.toXML(author, filters)
 	if err != nil {
 		return err
@@ -79,7 +79,7 @@ func (x Exporter) Export(author cfgv2.Author, filters filter.Filters, w io.Write
 	return err
 }
 
-func (x Exporter) toXML(author cfgv2.Author, filters filter.Filters) (xmlDoc, error) {
+func (x Exporter) toXML(author v1alpha3.Author, filters filter.Filters) (xmlDoc, error) {
 	entries, err := x.entriesToXML(filters)
 	res := xmlDoc{
 		XMLNS:       "http://www.w3.org/2005/Atom",
diff --git a/pkg/label/label.go b/pkg/label/label.go
index b0f1d71..f7ca657 100644
--- a/pkg/label/label.go
+++ b/pkg/label/label.go
@@ -5,7 +5,7 @@ import (
 	"fmt"
 	"strings"
 
-	cfgv3 "github.com/mbrt/gmailctl/pkg/config/v1alpha3"
+	"github.com/mbrt/gmailctl/pkg/config/v1alpha3"
 )
 
 // Labels is a list of labels.
@@ -102,7 +102,7 @@ func Equivalent(upstream, local Label) bool {
 }
 
 // FromConfig creates labels from the config format.
-func FromConfig(ls []cfgv3.Label) Labels {
+func FromConfig(ls []v1alpha3.Label) Labels {
 	var res Labels
 
 	for _, l := range ls {
diff --git a/pkg/rimport/jsonnet.go b/pkg/rimport/jsonnet.go
new file mode 100644
index 0000000..36cba49
--- /dev/null
+++ b/pkg/rimport/jsonnet.go
@@ -0,0 +1,75 @@
+package rimport
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/json"
+	"io"
+	"regexp"
+)
+
+const labelsComment = `  // Note: labels management is optional. If you prefer to use the
+  // GMail interface to add and remove labels, you can safely remove
+  // this section of the config.
+`
+
+var labelsLine = "  labels: ["
+
+func MarshalJsonnet(v interface{}, w io.Writer, header string) error {
+	// Convert to JSON
+	b, err := json.MarshalIndent(v, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	// Make JSON resemble Jsonnet by getting rid of unnecessary quotes
+	reader := bufio.NewReader(bytes.NewReader(b))
+	writer := bufio.NewWriter(w)
+	keyRe := regexp.MustCompile(`^ *"([a-zA-Z01]+)":`)
+	var line []byte
+
+	_, err = writer.WriteString(header)
+	if err != nil {
+		return err
+	}
+
+	line, _, err = reader.ReadLine()
+	for err == nil {
+		line = replaceGroupsRe(keyRe, line)
+		if string(line) == labelsLine {
+			_, err = writer.WriteString(labelsComment)
+			if err != nil {
+				break
+			}
+		}
+		_, err = writer.Write(line)
+		if err != nil {
+			break
+		}
+		_, err = writer.WriteRune('\n')
+		if err != nil {
+			break
+		}
+
+		line, _, err = reader.ReadLine()
+	}
+
+	if err == io.EOF {
+		return writer.Flush()
+	}
+	return err
+}
+
+func replaceGroupsRe(re *regexp.Regexp, in []byte) []byte {
+	m := re.FindSubmatchIndex(in)
+	if len(m) == 0 {
+		return in
+	}
+	keyb, keye := m[2], m[3]
+
+	var res []byte
+	res = append(res, in[:keyb-1]...)
+	res = append(res, in[keyb:keye]...)
+	res = append(res, in[keye+1:]...)
+	return res
+}


Test patch:
diff --git a/pkg/config/v1alpha2/import_test.go b/cmd/gmailctl-config-migrate/v1alpha2/import_test.go
similarity index 76%
rename from pkg/config/v1alpha2/import_test.go
rename to cmd/gmailctl-config-migrate/v1alpha2/import_test.go
index 098c3d1..bf29a61 100644
--- a/pkg/config/v1alpha2/import_test.go
+++ b/cmd/gmailctl-config-migrate/v1alpha2/import_test.go
@@ -10,7 +10,7 @@ import (
 	"github.com/stretchr/testify/assert"
 	"gopkg.in/yaml.v2"
 
-	v1 "github.com/mbrt/gmailctl/pkg/config/v1alpha1"
+	"github.com/mbrt/gmailctl/cmd/gmailctl-config-migrate/v1alpha1"
 )
 
 func read(path string) []byte {
@@ -21,8 +21,8 @@ func read(path string) []byte {
 	return b
 }
 
-func parseV1(t *testing.T, path string) v1.Config {
-	var res v1.Config
+func parseV1(t *testing.T, path string) v1alpha1.Config {
+	var res v1alpha1.Config
 	if err := yaml.UnmarshalStrict(read(path), &res); err != nil {
 		t.Fatal(err)
 	}
@@ -72,20 +72,20 @@ func TestConvert(t *testing.T) {
 }
 
 func TestWrongConsts(t *testing.T) {
-	v1c := v1.Config{
-		Consts: v1.Consts{
-			"c1": v1.ConstValue{Values: []string{"foo"}},
+	v1c := v1alpha1.Config{
+		Consts: v1alpha1.Consts{
+			"c1": v1alpha1.ConstValue{Values: []string{"foo"}},
 		},
-		Rules: []v1.Rule{
+		Rules: []v1alpha1.Rule{
 			{
-				Filters: v1.Filters{
-					Consts: v1.CompositeFilters{
-						MatchFilters: v1.MatchFilters{
+				Filters: v1alpha1.Filters{
+					Consts: v1alpha1.CompositeFilters{
+						MatchFilters: v1alpha1.MatchFilters{
 							From: []string{"c2"},
 						},
 					},
 				},
-				Actions: v1.Actions{},
+				Actions: v1alpha1.Actions{},
 			},
 		},
 	}
diff --git a/pkg/config/v1alpha3/import_test.go b/cmd/gmailctl-config-migrate/v1alpha3/import_test.go
similarity index 77%
rename from pkg/config/v1alpha3/import_test.go
rename to cmd/gmailctl-config-migrate/v1alpha3/import_test.go
index fe85427..efa5042 100644
--- a/pkg/config/v1alpha3/import_test.go
+++ b/cmd/gmailctl-config-migrate/v1alpha3/import_test.go
@@ -11,7 +11,8 @@ import (
 	"github.com/stretchr/testify/assert"
 	"gopkg.in/yaml.v2"
 
-	v2 "github.com/mbrt/gmailctl/pkg/config/v1alpha2"
+	"github.com/mbrt/gmailctl/cmd/gmailctl-config-migrate/v1alpha2"
+	"github.com/mbrt/gmailctl/pkg/config/v1alpha3"
 )
 
 func read(path string) []byte {
@@ -22,23 +23,23 @@ func read(path string) []byte {
 	return b
 }
 
-func parseV2(t *testing.T, path string) v2.Config {
-	var res v2.Config
+func parseV2(t *testing.T, path string) v1alpha2.Config {
+	var res v1alpha2.Config
 	if err := yaml.UnmarshalStrict(read(path), &res); err != nil {
 		t.Fatal(err)
 	}
 	return res
 }
 
-func parseV3(t *testing.T, path string) Config {
-	var res Config
+func parseV3(t *testing.T, path string) v1alpha3.Config {
+	var res v1alpha3.Config
 	if err := json.Unmarshal(read(path), &res); err != nil {
 		t.Fatal(err)
 	}
 	return res
 }
 
-func dump(t *testing.T, cfg Config) string {
+func dump(t *testing.T, cfg v1alpha3.Config) string {
 	b, err := yaml.Marshal(cfg)
 	if err != nil {
 		t.Fatal(err)
diff --git a/cmd/gmailctl/cmd/test_cmd.go b/cmd/gmailctl/cmd/test_cmd.go
index a6392c9..b7d702b 100644
--- a/cmd/gmailctl/cmd/test_cmd.go
+++ b/cmd/gmailctl/cmd/test_cmd.go
@@ -21,7 +21,7 @@ List of unsupported constructs:
 * Raw queries (pkg/config/v1alpha3/FilterNode.Query).
 
 By default test uses the configuration file inside the config
-directory [config.(yaml|jsonnet)].`,
+directory [config.jsonnet].`,
 	Run: func(cmd *cobra.Command, args []string) {
 		f := testFilename
 		if f == "" {
diff --git a/integration_test.go b/integration_test.go
index 9af9c99..6d1dd69 100644
--- a/integration_test.go
+++ b/integration_test.go
@@ -20,7 +20,7 @@ import (
 	"github.com/mbrt/gmailctl/pkg/api"
 	"github.com/mbrt/gmailctl/pkg/apply"
 	"github.com/mbrt/gmailctl/pkg/config"
-	"github.com/mbrt/gmailctl/pkg/config/v1alpha2"
+	"github.com/mbrt/gmailctl/pkg/config/v1alpha3"
 	"github.com/mbrt/gmailctl/pkg/export/xml"
 	"github.com/mbrt/gmailctl/pkg/rimport"
 )
@@ -58,7 +58,7 @@ func TestIntegration(t *testing.T) {
 			xmlexp := xml.NewWithTime(func() time.Time { return fixedTime })
 			var cfgxml bytes.Buffer
 			bw := bufio.NewWriter(&cfgxml)
-			author := v1alpha2.Author{Name: "Me", Email: "me@gmail.com"}
+			author := v1alpha3.Author{Name: "Me", Email: "me@gmail.com"}
 			err = xmlexp.Export(author, pres.Filters, bw)
 			bw.Flush() // Make sure everything is written out.
 			require.Nil(t, err)
@@ -81,6 +81,38 @@ func TestIntegration(t *testing.T) {
 			icfgJSON, err := json.MarshalIndent(icfg, "", "  ")
 			require.Nil(t, err)
 
+			// There should be no diff now with upstream.
+			d2, err := apply.Diff(pres.GmailConfig, upres)
+			require.Nil(t, err)
+			assert.Empty(t, d2.FiltersDiff)
+			assert.Empty(t, d2.LabelsDiff)
+
+			// There should be no diff between the original and the imported config.
+			ipres, err := apply.FromConfig(icfg)
+			require.Nil(t, err)
+			d3, err := apply.Diff(ipres.GmailConfig, upres)
+			require.Nil(t, err)
+			assert.Empty(t, d3.FiltersDiff)
+			assert.Empty(t, d3.LabelsDiff)
+
+			// Import and convert to Jsonnet.
+			var buf bytes.Buffer
+			w := bufio.NewWriter(&buf)
+			err = rimport.MarshalJsonnet(icfg, w, "// Download header.\n")
+			require.Nil(t, err)
+			err = w.Flush()
+			require.Nil(t, err)
+
+			// There should be no diff between the original and the converted config.
+			ijcfg, err := config.ReadJsonnet("", buf.Bytes())
+			require.Nil(t, err)
+			ijpres, err := apply.FromConfig(ijcfg)
+			require.Nil(t, err)
+			d4, err := apply.Diff(ijpres.GmailConfig, upres)
+			require.Nil(t, err)
+			assert.Empty(t, d4.FiltersDiff)
+			assert.Empty(t, d4.LabelsDiff)
+
 			// Compare the results with the golden files (or update the golden files).
 			if *update {
 				// Import.
diff --git a/pkg/apply/apply_test.go b/pkg/apply/apply_test.go
deleted file mode 100644
index 0b2023c..0000000
--- a/pkg/apply/apply_test.go
+++ /dev/null
@@ -1,233 +0,0 @@
-package apply
-
-import (
-	"flag"
-	"fmt"
-	"io/ioutil"
-	"math/rand"
-	"path/filepath"
-	"sort"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-
-	cfg "github.com/mbrt/gmailctl/pkg/config"
-	cfgv3 "github.com/mbrt/gmailctl/pkg/config/v1alpha3"
-	"github.com/mbrt/gmailctl/pkg/filter"
-	"github.com/mbrt/gmailctl/pkg/label"
-)
-
-const testdataDir = "testdata"
-
-// update is useful to regenerate the diff files, whenever necessary.
-// Make sure the new version makes sense!!
-var update = flag.Bool("update", false, "update .diff files")
-
-func read(t *testing.T, path string) []byte {
-	t.Helper()
-	b, err := ioutil.ReadFile(path)
-	if err != nil {
-		t.Fatal(err)
-	}
-	return b
-}
-
-func readConfig(t *testing.T, path string) cfgv3.Config {
-	t.Helper()
-	res, err := cfg.ReadFile(path, filepath.Join(testdataDir, path))
-	if err != nil {
-		t.Fatal(err)
-	}
-	return res
-}
-
-type testPaths struct {
-	locals []string
-	diffs  []string
-}
-
-func globTestdataPaths(t *testing.T, pattern string) []string {
-	t.Helper()
-	fs, err := filepath.Glob(filepath.Join(testdataDir, pattern))
-	if err != nil {
-		t.Fatal(err)
-	}
-	return fs
-}
-
-func allTestPaths(t *testing.T) testPaths {
-	t.Helper()
-	local := globTestdataPaths(t, "local.*.yaml")
-	local = append(local, globTestdataPaths(t, "local.*.jsonnet")...)
-	tp := testPaths{
-		locals: local,
-		diffs:  globTestdataPaths(t, "local.*.diff"),
-	}
-	if len(tp.locals) != len(tp.diffs) {
-		t.Fatal("expected both local and diff files to be present")
-	}
-
-	// Get the files in the same order
-	sort.Strings(tp.locals)
-	sort.Strings(tp.diffs)
-
-	return tp
-}
-
-func parseConfig(t *testing.T, path string) ConfigParseRes {
-	t.Helper()
-	config := readConfig(t, path)
-	r, err := FromConfig(config)
-	if err != nil {
-		t.Fatal(err)
-	}
-	return r
-}
-
-func shuffleConfig(cfg GmailConfig) {
-	fs := cfg.Filters
-	rand.Shuffle(len(fs), func(i, j int) {
-		fs[i], fs[j] = fs[j], fs[i]
-	})
-	ls := cfg.Labels
-	rand.Shuffle(len(ls), func(i, j int) {
-		ls[i], ls[j] = ls[j], ls[i]
-	})
-}
-
-func addIDs(cfg GmailConfig) {
-	for i := range cfg.Filters {
-		cfg.Filters[i].ID = fmt.Sprintf("filter%d", i)
-	}
-	for i := range cfg.Labels {
-		cfg.Labels[i].ID = fmt.Sprintf("label%d", i)
-	}
-}
-
-func labelIDs(ls label.Labels) []string {
-	var ret []string
-	for _, l := range ls {
-		ret = append(ret, l.ID)
-	}
-	return ret
-}
-
-func filterIDs(fs filter.Filters) []string {
-	var ret []string
-	for _, f := range fs {
-		ret = append(ret, f.ID)
-	}
-	return ret
-}
-
-func toLabels(ms []label.ModifiedLabel) label.Labels {
-	var lbs label.Labels
-	for _, m := range ms {
-		label := m.New
-		label.ID = m.Old.ID
-		lbs = append(lbs, label)
-	}
-	return lbs
-}
-
-type fakeAPI struct {
-	addLabels     label.Labels
-	updateLabels  label.Labels
-	deleteLabels  []string
-	addFilters    filter.Filters
-	deleteFilters []string
-}
-
-func (f *fakeAPI) AddLabels(lbs label.Labels) error {
-	f.addLabels = lbs
-	return nil
-}
-
-func (f *fakeAPI) AddFilters(fs filter.Filters) error {
-	f.addFilters = fs
-	return nil
-}
-
-func (f *fakeAPI) UpdateLabels(lbs label.Labels) error {
-	f.updateLabels = lbs
-	return nil
-}
-func (f *fakeAPI) DeleteFilters(ids []string) error {
-	f.deleteFilters = ids
-	return nil
-}
-func (f *fakeAPI) DeleteLabels(ids []string) error {
-	f.deleteLabels = ids
-	return nil
-}
-
-func TestDiff(t *testing.T) {
-	upstream := parseConfig(t, filepath.Join(testdataDir, "remote.jsonnet")).GmailConfig
-	tps := allTestPaths(t)
-
-	for i := 0; i < len(tps.locals); i++ {
-		local := tps.locals[i]
-
-		t.Run(local, func(t *testing.T) {
-			diffFile := tps.diffs[i]
-			config := parseConfig(t, local)
-
-			// Remote filters and labels can come in _any_ order
-			// We can make the test more realistic by shuffling them here
-			shuffleConfig(upstream)
-
-			diff, err := Diff(config.GmailConfig, upstream)
-			assert.Nil(t, err)
-
-			if *update {
-				// Update the golden files
-				err = ioutil.WriteFile(diffFile, []byte(diff.String()), 0600)
-				assert.Nil(t, err)
-			} else {
-				// Test them
-				expectedDiff := read(t, diffFile)
-				assert.Equal(t, string(expectedDiff), diff.String())
-			}
-		})
-	}
-}
-
-func TestApply(t *testing.T) {
-	upstream := parseConfig(t, filepath.Join(testdataDir, "remote.jsonnet")).GmailConfig
-	tps := allTestPaths(t)
-
-	for i := 0; i < len(tps.locals); i++ {
-		local := tps.locals[i]
-
-		t.Run(local, func(t *testing.T) {
-			config := parseConfig(t, local)
-			api := fakeAPI{}
-
-			// Remote filters and labels can come in _any_ order
-			// We can make the test more realistic by shuffling them here
-			shuffleConfig(upstream)
-			addIDs(upstream)
-
-			diff, err := Diff(config.GmailConfig, upstream)
-			assert.Nil(t, err)
-
-			// Apply without removing labels
-			err = Apply(diff, &api, false)
-			assert.Nil(t, err)
-			assert.Equal(t, diff.FiltersDiff.Added, api.addFilters)
-			assert.Equal(t, filterIDs(diff.FiltersDiff.Removed), api.deleteFilters)
-			assert.Equal(t, diff.LabelsDiff.Added, api.addLabels)
-			assert.Equal(t, toLabels(diff.LabelsDiff.Modified), api.updateLabels)
-			// Labels are never removed
-			assert.Empty(t, api.deleteLabels)
-
-			// Apply with removing labels
-			if len(diff.LabelsDiff.Removed) > 0 {
-				err = Apply(diff, &api, true)
-				assert.Nil(t, err)
-				assert.Equal(t, labelIDs(diff.LabelsDiff.Removed), api.deleteLabels)
-			}
-
-		})
-	}
-}
diff --git a/pkg/apply/testdata/remote.jsonnet b/pkg/apply/testdata/remote.jsonnet
deleted file mode 100644
index dde49aa..0000000
--- a/pkg/apply/testdata/remote.jsonnet
+++ /dev/null
@@ -1,78 +0,0 @@
-local filters = {
-  me: {
-    or: [
-      {to: 'pippo@gmail.com'},
-      {to: 'pippo@hotmail.com'},
-    ]
-  },
-
-  notMe: {
-    not: self.me,
-  },
-
-  lists: {
-    or: [
-      {list: 'list1'},
-      {list: 'list2'},
-      {list: 'list3'},
-    ]
-  },
-
-  spam: {
-    or: [
-      {from: 'spammer1'},
-      {from: 'spammer2'},
-      {subject: 'spam mail'},
-      {to: 'pippo+spammy@gmail.com'},
-      {has: 'buy this thing'},
-      {has: 'very important!!!'},
-    ]
-  },
-};
-
-// The config
-{
-  version: 'v1alpha3',
-  labels: [
-    { name: 'maillist' },
-    { name: 'onemorelabel' },
-    {
-      name: 'label3',
-      color: {
-        background: '#cca6ac',
-        text: '#ffffff',
-      },
-    },
-  ],
-  rules: [
-    {
-      filter: {
-        and: [
-          filters.lists,
-          {not: filters.me},
-        ]
-      },
-      actions: {
-        labels: ['maillist', 'onemorelabel'],
-        archive: true,
-      }
-    },
-    {
-      filter: {
-        and: [
-          {to: 'myalias@gmail.com'},
-          filters.lists,
-        ]
-      },
-      actions: {
-        markImportant: true,
-      }
-    },
-    {
-      filter: filters.spam,
-      actions: {
-        delete: true,
-      }
-    },
-  ]
-}
diff --git a/pkg/cfgtest/exec_test.go b/pkg/cfgtest/exec_test.go
index 2f1828d..febe1ba 100644
--- a/pkg/cfgtest/exec_test.go
+++ b/pkg/cfgtest/exec_test.go
@@ -7,14 +7,14 @@ import (
 	"github.com/stretchr/testify/assert"
 
 	"github.com/mbrt/gmailctl/pkg/apply"
-	cfg "github.com/mbrt/gmailctl/pkg/config"
-	cfgv3 "github.com/mbrt/gmailctl/pkg/config/v1alpha3"
+	"github.com/mbrt/gmailctl/pkg/config"
+	"github.com/mbrt/gmailctl/pkg/config/v1alpha3"
 )
 
-func readConfig(t *testing.T, path string) cfgv3.Config {
+func readConfig(t *testing.T, path string) v1alpha3.Config {
 	t.Helper()
 	path = filepath.Join("testdata", path)
-	res, err := cfg.ReadFile(path, path)
+	res, err := config.ReadFile(path, path)
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/pkg/config/testdata/chainfilters.json b/pkg/config/testdata/chainfilters.json
index e4c2320..f91eddf 100644
--- a/pkg/config/testdata/chainfilters.json
+++ b/pkg/config/testdata/chainfilters.json
@@ -83,21 +83,6 @@
           "mylist"
         ]
       }
-    },
-    {
-      "filter": {
-        "and": [
-          {
-            "from": "Google Docs"
-          },
-          {
-            "replyto": "pippo@gmail.com"
-          }
-        ]
-      },
-      "actions": {
-        "star": true
-      }
     }
   ]
 }
\ No newline at end of file
diff --git a/pkg/config/testdata/chainfilters.jsonnet b/pkg/config/testdata/chainfilters.jsonnet
index 50fc1c2..b367c4c 100644
--- a/pkg/config/testdata/chainfilters.jsonnet
+++ b/pkg/config/testdata/chainfilters.jsonnet
@@ -25,21 +25,6 @@ local ruleC = {
     labels: ['mylist'],
   },
 };
-local ruleD = {
-  filter: {
-    and: [
-      {
-        from: 'Google Docs'
-        },
-      {
-        replyto: me,
-      },
-    ],
-  },
-  actions: {
-      star: true,
-    },
-};
 
 {
   version: 'v1alpha3',
@@ -58,6 +43,4 @@ local ruleD = {
   + lib.chainFilters([ruleB])
   // Chain 3 rules
   + lib.chainFilters([ruleA, ruleB, ruleC])
-  // Checks replyto is different than from
-  + lib.chainFilters([ruleD])
 }
diff --git a/pkg/export/xml/marshal_test.go b/pkg/export/xml/marshal_test.go
index 38975df..2efed34 100644
--- a/pkg/export/xml/marshal_test.go
+++ b/pkg/export/xml/marshal_test.go
@@ -8,7 +8,7 @@ import (
 
 	"github.com/stretchr/testify/assert"
 
-	cfgv2 "github.com/mbrt/gmailctl/pkg/config/v1alpha2"
+	"github.com/mbrt/gmailctl/pkg/config/v1alpha3"
 	"github.com/mbrt/gmailctl/pkg/filter"
 	"github.com/mbrt/gmailctl/pkg/gmail"
 )
@@ -21,7 +21,7 @@ func testNow() time.Time {
 
 func TestEmptyEntries(t *testing.T) {
 	exporter := Exporter{now: testNow}
-	author := cfgv2.Author{Name: "Pippo Pluto", Email: "pippo@mail.com"}
+	author := v1alpha3.Author{Name: "Pippo Pluto", Email: "pippo@mail.com"}
 	buf := new(bytes.Buffer)
 	err := exporter.Export(author, filter.Filters{}, buf)
 	assert.Nil(t, err)
@@ -41,7 +41,7 @@ func TestEmptyEntries(t *testing.T) {
 
 func TestSomeEntries(t *testing.T) {
 	exporter := Exporter{now: testNow}
-	author := cfgv2.Author{Name: "Pippo Pluto", Email: "pippo@mail.com"}
+	author := v1alpha3.Author{Name: "Pippo Pluto", Email: "pippo@mail.com"}
 	filters := filter.Filters{
 		{
 			Action: filter.Actions{
@@ -96,7 +96,7 @@ func TestSomeEntries(t *testing.T) {
 
 func TestAllEntries(t *testing.T) {
 	exporter := Exporter{now: testNow}
-	author := cfgv2.Author{Name: "Pippo Pluto", Email: "pippo@mail.com"}
+	author := v1alpha3.Author{Name: "Pippo Pluto", Email: "pippo@mail.com"}
 	filters := filter.Filters{
 		{
 			Action: filter.Actions{