Repository: mbrt/gmailctl (go)

Issue:
Add "forward" action (to forward emails)
"Forward it to: Choose an address.." from Gmail's filter creation is missing as an Action option in gmailctl.

The [Gmail API](https://developers.google.com/gmail/api/v1/reference/users/settings/filters) supports it as `action.forward`. The parameter is a string containing the forwarding email. I confirmed this with the API explorer.

However, forward is different because it's not a pseudo-label like gmailctl's other actions.

`user.settings.filters.create` returns an error if the forwarding address is not set up on the user's account:

```json
{
 "error": {
  "errors": [
   {
    "domain": "global",
    "reason": "failedPrecondition",
    "message": "Unrecognized forwarding address"
   }
  ],
  "code": 400,
  "message": "Unrecognized forwarding address"
 }
}
```

The same error happens for malformed input, like `@` or `[][]`. **The empty string succeeds as if `forward` was absent.**

Should we care about the case where a user wants one filter to forward to multiple email addresses? It's probably rare, and I'm not sure how Gmail would react to the duplicate queries with different forwarding addresses.

I'm blocked from using gmailctl for my personal email because I have 3 filters that forward my email. I forward my award point statements to AwardWallet (so that it doesn't have permissions to read my entire mailbox).

I looked at the code a bit, and I may try implementing this in the next week or so.

Pull request:
Implement forward action
This is a ripoff and fix of #58.

Closes #56.

Patch:
diff --git a/README.md b/README.md
index c6f6f00..419c489 100644
--- a/README.md
+++ b/README.md
@@ -361,7 +361,10 @@ are the same as the ones in the Gmail interface:
 * `labels: [list, of, labels]`: an array of labels to apply to the message. Note
   that these labels have to be already present in your settings (they won't be
   created automatically), and you can specify multiple labels (normally Gmail
-  allows to specify only one label per filter).
+* `forward: 'forward@to.com'`: forward the message to another email address. The
+  forwarding address must be already in your settings (Forwarding and POP/IMAP >
+  Add a forwarding address). Gmail allows no more than 20 forwarding filters.
+  Only one address can be specified for one filter.
 
 Example:
 
diff --git a/pkg/export/api/api_export.go b/pkg/export/api/api_export.go
index 66a9273..1bb8c3f 100644
--- a/pkg/export/api/api_export.go
+++ b/pkg/export/api/api_export.go
@@ -68,6 +68,7 @@ func exportAction(action filter.Actions, lmap LabelMap) (*gmailv1.FilterAction,
 	return &gmailv1.FilterAction{
 		AddLabelIds:    lops.addLabels,
 		RemoveLabelIds: lops.removeLabels,
+		Forward:        action.Forward,
 	}, nil
 }
 
diff --git a/pkg/export/api/api_import.go b/pkg/export/api/api_import.go
index 81f70e6..bb9cf9e 100644
--- a/pkg/export/api/api_import.go
+++ b/pkg/export/api/api_import.go
@@ -56,12 +56,15 @@ func importAction(action *gmailv1.FilterAction, lmap LabelMap) (filter.Actions,
 	if err := importAddLabels(&res, action.AddLabelIds, lmap); err != nil {
 		return res, err
 	}
-	err := importRemoveLabels(&res, action.RemoveLabelIds)
+	if err := importRemoveLabels(&res, action.RemoveLabelIds); err != nil {
+		return res, err
+	}
+	res.Forward = action.Forward
 
 	if res.Empty() {
 		return res, errors.New("empty or unsupported action")
 	}
-	return res, err
+	return res, nil
 }
 
 func importAddLabels(res *filter.Actions, addLabelIDs []string, lmap LabelMap) error {
diff --git a/pkg/export/xml/consts.go b/pkg/export/xml/consts.go
index 9347df9..21f3a7a 100644
--- a/pkg/export/xml/consts.go
+++ b/pkg/export/xml/consts.go
@@ -24,6 +24,7 @@ const (
 	PropertyMarkRead         = "shouldMarkAsRead"
 	PropertyMarkNotSpam      = "shouldNeverSpam"
 	PropertyStar             = "shouldStar"
+	PropertyForward          = "forwardTo"
 )
 
 // SmartLabel values
diff --git a/pkg/export/xml/marshal.go b/pkg/export/xml/marshal.go
index e625111..dfbeaa0 100644
--- a/pkg/export/xml/marshal.go
+++ b/pkg/export/xml/marshal.go
@@ -143,6 +143,7 @@ func (x xmlExporter) actionProperties(a filter.Actions) ([]xmlProperty, error) {
 	res = x.appendBoolProperty(res, PropertyMarkNotSpam, a.MarkNotSpam)
 	res = x.appendBoolProperty(res, PropertyStar, a.Star)
 	res = x.appendStringProperty(res, PropertyApplyLabel, a.AddLabel)
+	res = x.appendStringProperty(res, PropertyForward, a.Forward)
 
 	if a.Category != "" {
 		cat, err := categoryToSmartLabel(a.Category)
diff --git a/pkg/filter/convert.go b/pkg/filter/convert.go
index 261a548..44d3749 100644
--- a/pkg/filter/convert.go
+++ b/pkg/filter/convert.go
@@ -264,6 +264,7 @@ func generateActions(actions parser.Actions) ([]Actions, error) {
 			Category:         actions.Category,
 			MarkNotSpam:      fromOptionalBool(actions.MarkSpam, false),
 			Star:             actions.Star,
+			Forward:          actions.Forward,
 		},
 	}
 
diff --git a/pkg/filter/filter.go b/pkg/filter/filter.go
index 015c987..4de220c 100644
--- a/pkg/filter/filter.go
+++ b/pkg/filter/filter.go
@@ -62,6 +62,7 @@ func (f Filter) String() string {
 	w.WriteBool("star", f.Action.Star)
 	w.WriteParam("categorize as", string(f.Action.Category))
 	w.WriteParam("apply label", f.Action.AddLabel)
+	w.WriteParam("forward to", f.Action.Forward)
 
 	return w.String()
 }
@@ -82,6 +83,7 @@ type Actions struct {
 	MarkRead         bool
 	MarkNotSpam      bool
 	Star             bool
+	Forward          string
 }
 
 // Empty returns true if no action is specified.
diff --git a/pkg/rimport/rimport.go b/pkg/rimport/rimport.go
index de1cd93..c258b46 100644
--- a/pkg/rimport/rimport.go
+++ b/pkg/rimport/rimport.go
@@ -122,6 +122,7 @@ func fromActions(c filter.Actions) (config.Actions, error) {
 		Delete:   c.Delete,
 		MarkRead: c.MarkRead,
 		Star:     c.Star,
+		Forward:  c.Forward,
 	}
 	if c.AddLabel != "" {
 		res.Labels = []string{c.AddLabel}


Test patch:
diff --git a/pkg/export/api/api_export_test.go b/pkg/export/api/api_export_test.go
index ad3eaa4..fb32684 100644
--- a/pkg/export/api/api_export_test.go
+++ b/pkg/export/api/api_export_test.go
@@ -26,6 +26,7 @@ func TestExportActions(t *testing.T) {
 				MarkNotSpam:   true,
 				MarkImportant: true,
 				Category:      gmail.CategoryUpdates,
+				Forward:       "baz@zuz.it",
 			},
 			Criteria: filter.Criteria{
 				From: "foo@bar.com",
@@ -47,6 +48,7 @@ func TestExportActions(t *testing.T) {
 					labelIDUnread,
 					labelIDSpam,
 				},
+				Forward: "baz@zuz.it",
 			},
 			Criteria: &gmailv1.FilterCriteria{
 				From: "foo@bar.com",
diff --git a/pkg/export/api/api_import_test.go b/pkg/export/api/api_import_test.go
index 967ef62..f590c99 100644
--- a/pkg/export/api/api_import_test.go
+++ b/pkg/export/api/api_import_test.go
@@ -26,6 +26,7 @@ func TestImportActions(t *testing.T) {
 					labelIDUnread,
 					labelIDSpam,
 				},
+				Forward: "baz@zuz.it",
 			},
 			Criteria: &gmailv1.FilterCriteria{
 				From: "foo@bar.com",
@@ -43,6 +44,7 @@ func TestImportActions(t *testing.T) {
 				MarkNotSpam:   true,
 				MarkImportant: true,
 				Category:      gmail.CategoryUpdates,
+				Forward:       "baz@zuz.it",
 			},
 			Criteria: filter.Criteria{
 				From: "foo@bar.com",
diff --git a/pkg/export/xml/marshal_test.go b/pkg/export/xml/marshal_test.go
index 4a0a556..5d6c2fe 100644
--- a/pkg/export/xml/marshal_test.go
+++ b/pkg/export/xml/marshal_test.go
@@ -106,6 +106,7 @@ func TestAllEntries(t *testing.T) {
 				MarkRead:      true,
 				Category:      gmail.CategoryPromotions,
 				AddLabel:      "MyLabel",
+				Forward:       "baz@zuz.it",
 			},
 			Criteria: filter.Criteria{
 				From:    "foo@baz.com",
@@ -141,6 +142,7 @@ func TestAllEntries(t *testing.T) {
     <apps:property name="shouldAlwaysMarkAsImportant" value="true"></apps:property>
     <apps:property name="shouldMarkAsRead" value="true"></apps:property>
     <apps:property name="label" value="MyLabel"></apps:property>
+    <apps:property name="forwardTo" value="baz@zuz.it"></apps:property>
     <apps:property name="smartLabelToApply" value="^smartlabel_promo"></apps:property>
   </entry>
 </feed>`
diff --git a/pkg/filter/convert_test.go b/pkg/filter/convert_test.go
index 75c73c0..e95bad4 100644
--- a/pkg/filter/convert_test.go
+++ b/pkg/filter/convert_test.go
@@ -223,6 +223,7 @@ func TestActions(t *testing.T) {
 				MarkSpam:      boolptr(false),
 				MarkImportant: boolptr(true),
 				Category:      gmail.CategoryForums,
+				Forward:       "foo@bar.com",
 			},
 		},
 	}
@@ -239,6 +240,7 @@ func TestActions(t *testing.T) {
 				MarkNotSpam:   true,
 				MarkImportant: true,
 				Category:      gmail.CategoryForums,
+				Forward:       "foo@bar.com",
 			},
 		},
 	}
diff --git a/testdata/local.bigdiff.diff b/testdata/local.bigdiff.diff
index cd5c5f3..88aa026 100644
--- a/testdata/local.bigdiff.diff
+++ b/testdata/local.bigdiff.diff
@@ -4,29 +4,22 @@ Filters:
 @@ -1,32 +1,55 @@
  * Criteria:
 -    query: {"buy this thing" "very important!!!"}
-+    from: spammer2
++    query: list:foobaz.mail.com -"action needed"
    Actions:
      delete
  
  * Criteria:
--    to: myalias@gmail.com
--    query: list:{list1 list2 list3}
-+    from: baz+zuz@mail.com
-   Actions:
-     mark as important
- 
- * Criteria:
--    from: {spammer1 spammer2}
-+    query: "buy this thing"
++    from: spammer1
+     subject: "spam mail"
    Actions:
      delete
  
  * Criteria:
--    query: list:{list1 list2 list3} -to:{pippo@gmail.com pippo@hotmail.com}
-+    query: list:{list3 list1 list4 list6} -to:none@gmail.com
+-    to: myalias@gmail.com
+-    query: list:{list1 list2 list3}
++    from: baz+zuz@mail.com
    Actions:
--    apply label: onemorelabel
-+    apply label: differentlabel
+     mark as important
  
  * Criteria:
 -    query: list:{list1 list2 list3} -to:{pippo@gmail.com pippo@hotmail.com}
@@ -36,25 +29,29 @@ Filters:
      apply label: maillist
  
  * Criteria:
-+    from: spammer1
-     subject: "spam mail"
+-    query: list:{list1 list2 list3} -to:{pippo@gmail.com pippo@hotmail.com}
+-  Actions:
+-    apply label: onemorelabel
+-
+-* Criteria:
+-    from: {spammer1 spammer2}
++    query: "buy this thing"
    Actions:
      delete
  
 +* Criteria:
-+    from: notfriend@gmail.com
-+    subject: "hey there"
++    from: spammer2
 +  Actions:
-+    archive
-+    star
++    delete
 +
 +* Criteria:
-+    query: list:foobaz.mail.com -"action needed"
++    query: list:{list3 list1 list4 list6} -to:none@gmail.com
 +  Actions:
-+    delete
++    apply label: differentlabel
 +
 +* Criteria:
-+    query: -to:none@gmail.com
++    from: notfriend@gmail.com
++    subject: "hey there"
 +  Actions:
 +    archive
 +    star
@@ -64,3 +61,9 @@ Filters:
 +  Actions:
 +    apply label: thirdlabel
 +
++* Criteria:
++    query: -to:none@gmail.com
++  Actions:
++    archive
++    star
++
diff --git a/testdata/local.bigdiffj.diff b/testdata/local.bigdiffj.diff
new file mode 100644
index 0000000..86f829d
--- /dev/null
+++ b/testdata/local.bigdiffj.diff
@@ -0,0 +1,65 @@
+Filters:
+--- Current
++++ TO BE APPLIED
+@@ -1,32 +1,51 @@
+ * Criteria:
+-    query: {"buy this thing" "very important!!!"}
++    query: list:foobaz.mail.com -"action needed"
+   Actions:
+     delete
+ 
+ * Criteria:
++    from: spammer1
+     subject: "spam mail"
+   Actions:
+     delete
+ 
+ * Criteria:
+-    to: myalias@gmail.com
+-    query: list:{list1 list2 list3}
++    from: baz+zuz@mail.com
+   Actions:
+     mark as important
++    forward to: other@mail.com
+ 
+ * Criteria:
+-    query: list:{list1 list2 list3} -to:{pippo@gmail.com pippo@hotmail.com}
++    query: list:{list3 list1 list4 list6} -to:none@gmail.com
+   Actions:
+     archive
+     apply label: maillist
+ 
+ * Criteria:
+-    query: list:{list1 list2 list3} -to:{pippo@gmail.com pippo@hotmail.com}
+-  Actions:
+-    apply label: onemorelabel
+-
+-* Criteria:
+-    from: {spammer1 spammer2}
++    query: "buy this thing"
+   Actions:
+     delete
+ 
++* Criteria:
++    from: spammer2
++  Actions:
++    delete
++
++* Criteria:
++    query: list:{list3 list1 list4 list6} -to:none@gmail.com
++  Actions:
++    apply label: differentlabel
++
++* Criteria:
++    query: list:{list3 list1 list4 list6} -to:none@gmail.com
++  Actions:
++    apply label: thirdlabel
++
++* Criteria:
++    from: notfriend@gmail.com
++    subject: "hey there"
++    query: -to:none@gmail.com
++  Actions:
++    archive
++    star
++
diff --git a/testdata/local.bigdiffj.jsonnet b/testdata/local.bigdiffj.jsonnet
new file mode 100644
index 0000000..5db0137
--- /dev/null
+++ b/testdata/local.bigdiffj.jsonnet
@@ -0,0 +1,89 @@
+local filters = {
+  me: {
+    or: [
+      {to: 'none@gmail.com'},
+    ]
+  },
+
+  notMe: {
+    not: filters.me,
+  },
+
+  lists: {
+    or: [
+      {list: 'list3'},
+      {list: 'list1'},
+      {list: 'list4'},
+      {list: 'list6'},
+    ]
+  },
+
+  spam: {
+    or: [
+      {
+        and: [
+          {from: 'spammer1'},
+          {subject: 'spam mail'},
+        ],
+      },
+      {from: 'spammer2'},
+      {
+        and: [
+          {list: 'foobaz.mail.com'},
+          {not: {has: 'action needed'}},
+        ],
+      },
+      {to: 'pippo+spammy@gmail.com'},
+      {has: 'buy this thing'},
+    ]
+  },
+};
+
+// The config
+{
+  version: 'v1alpha3',
+  rules: [
+    {
+      filter: {
+        and: [
+          filters.lists,
+          {not: filters.me},
+        ]
+      },
+      actions: {
+        labels: ['maillist', 'differentlabel', 'thirdlabel'],
+        archive: true,
+      }
+    },
+    {
+      filter: filters.spam,
+      actions: {
+        delete: true,
+      }
+    },
+    {
+      filter: {from: 'baz+zuz@mail.com'},
+      actions: {
+        markImportant: true,
+        forward: 'other@mail.com',
+      }
+    },
+    {
+      filter: {
+        or: [
+          {
+            and: [
+              {subject: 'hey there'},
+              {from: 'notfriend@gmail.com'},
+              filters.notMe,
+            ],
+          }
+        ],
+      },
+      actions: {
+        archive: true,
+        star: true,
+      }
+    },
+  ]
+}
diff --git a/testdata/local.deleteall.diff b/testdata/local.deleteall.diff
index 4cb8fa3..7428421 100644
--- a/testdata/local.deleteall.diff
+++ b/testdata/local.deleteall.diff
@@ -14,7 +14,7 @@ Filters:
 -    delete
 -
 -* Criteria:
--    query: {"buy this thing" "very important!!!"}
+-    to: pippo+spammy@gmail.com
 -  Actions:
 -    delete
 -
@@ -24,7 +24,7 @@ Filters:
 -    apply label: onemorelabel
 -
 -* Criteria:
--    subject: "spam mail"
+-    query: {"buy this thing" "very important!!!"}
 -  Actions:
 -    delete
 -
@@ -35,7 +35,7 @@ Filters:
 -    apply label: maillist
 -
 -* Criteria:
--    to: pippo+spammy@gmail.com
+-    subject: "spam mail"
 -  Actions:
 -    delete
 -