11 Commits
v0.1 ... v0.7

Author SHA1 Message Date
bel
3077343c89 add anti-patterns, print acc for ledger, fix chase 2021 from body.read double 2021-07-30 06:28:21 -06:00
bel
24b66d1c46 filter accounts by pattern 2021-07-30 00:07:34 -06:00
bel
a6c5121d54 check ledger output a little bit 2021-07-29 23:42:16 -06:00
bel
1ebaf9eac8 test new and old scrape for chase 2021-07-29 23:38:27 -06:00
bel
cea7a30884 chagne account to vendor 2021-07-29 22:58:01 -06:00
bel
c5c77a2b9b write banks diff for ledger, filter by bank 2021-07-29 22:50:17 -06:00
bel
bbd51ea9c5 support ledger file append 2021-07-29 22:24:30 -06:00
Bel LaPointe
f32fb5aad1 Impl uccu 2020-07-13 14:55:20 -06:00
Bel LaPointe
4006975a21 Update chase scrape 2020-07-01 05:26:20 -06:00
bel
f5a40f7890 Implement citi scrape 2020-04-04 20:44:30 +00:00
bel
9f3f3dc08f Split task format from internal storage 2020-04-03 00:18:39 +00:00
12 changed files with 922 additions and 77 deletions

0
.gitignore vendored Normal file → Executable file
View File

3
bank.go Normal file → Executable file
View File

@@ -5,6 +5,7 @@ type Bank int
const ( const (
Chase Bank = iota + 1 Chase Bank = iota + 1
Citi Bank = iota + 1 Citi Bank = iota + 1
UCCU Bank = iota + 1
) )
func (b Bank) String() string { func (b Bank) String() string {
@@ -13,6 +14,8 @@ func (b Bank) String() string {
return "Chase" return "Chase"
case Citi: case Citi:
return "Citi" return "Citi"
case UCCU:
return "UCCU"
} }
return "?" return "?"
} }

119
config.go Normal file → Executable file
View File

@@ -2,6 +2,8 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io/ioutil"
"local/args" "local/args"
"local/oauth2" "local/oauth2"
"local/storage" "local/storage"
@@ -9,6 +11,18 @@ import (
"strings" "strings"
) )
type Uploader int
const (
UploaderTodo = Uploader(iota)
UploaderLedger
)
var uploaders = map[string]Uploader{
"todo": UploaderTodo,
"ledger": UploaderLedger,
}
type Config struct { type Config struct {
EmailUser string EmailUser string
EmailPass string EmailPass string
@@ -17,7 +31,11 @@ type Config struct {
TodoToken string TodoToken string
TodoList string TodoList string
TodoTag string TodoTag string
Uploader Uploader
Storage storage.DB Storage storage.DB
Banks map[Bank]bool
AccountsPattern string
AccountsAntiPattern string
} }
var config Config var config Config
@@ -28,12 +46,19 @@ func NewConfig() Config {
as.Append(args.STRING, "emailuser", "email username", "breellocaldev@gmail.com") as.Append(args.STRING, "emailuser", "email username", "breellocaldev@gmail.com")
as.Append(args.STRING, "emailpass", "email password", "ML3WQRFSqe9rQ8qNkm") as.Append(args.STRING, "emailpass", "email password", "ML3WQRFSqe9rQ8qNkm")
as.Append(args.STRING, "emailimap", "email imap", "imap.gmail.com:993") as.Append(args.STRING, "emailimap", "email imap", "imap.gmail.com:993")
as.Append(args.STRING, "uploader", "todo, ledger", "todo")
as.Append(args.STRING, "todoaddr", "todo addr", "https://todo-server.remote.blapointe.com") as.Append(args.STRING, "todoaddr", "todo addr", "https://todo-server.remote.blapointe.com")
as.Append(args.STRING, "todopass", "todo pass", "gJtEXbbLHLf54yS9EdujtVN2n6Y") as.Append(args.STRING, "todopass", "todo pass", "gJtEXbbLHLf54yS9EdujtVN2n6Y")
as.Append(args.STRING, "todotoken", "todo token", "") as.Append(args.STRING, "todotoken", "todo token", "")
as.Append(args.STRING, "todolist", "todo list", "") as.Append(args.STRING, "todolist", "todo list", "")
as.Append(args.STRING, "todotag", "todo tag", "expense") as.Append(args.STRING, "todotag", "todo tag", "expense")
as.Append(args.STRING, "banks", "uccu,citi,chase", "uccu,citi,chase")
as.Append(args.STRING, "accounts", "regex to include filter accounts", ".*")
as.Append(args.STRING, "not-accounts", "regex to exclude filter accounts", "zzzzzz")
as.Append(args.STRING, "authaddr", "auth addr", "https://auth.remote.blapointe.com") as.Append(args.STRING, "authaddr", "auth addr", "https://auth.remote.blapointe.com")
as.Append(args.STRING, "store", "store type", "map") as.Append(args.STRING, "store", "store type", "map")
as.Append(args.STRING, "storeaddr", "store addr", "/tmp/store") as.Append(args.STRING, "storeaddr", "store addr", "/tmp/store")
@@ -44,13 +69,60 @@ func NewConfig() Config {
panic(err) panic(err)
} }
uploader := as.GetString("uploader")
ul, ok := uploaders[uploader]
if !ok {
panic("invalid uploader: " + uploader)
}
storage, err := storage.New(storage.TypeFromString(as.GetString("store")), as.GetString("storeaddr"), as.GetString("storeuser"), as.GetString("storepass"))
if err != nil {
panic(err)
}
config = Config{
EmailUser: as.GetString("emailuser"),
EmailPass: as.GetString("emailpass"),
EmailIMAP: as.GetString("emailimap"),
TodoAddr: as.GetString("todoaddr"),
TodoTag: as.GetString("todotag"),
AccountsPattern: as.GetString("accounts"),
AccountsAntiPattern: as.GetString("not-accounts"),
Storage: storage,
Uploader: ul,
Banks: map[Bank]bool{
Chase: strings.Contains(strings.ToLower(as.GetString("banks")), strings.ToLower(Chase.String())),
Citi: strings.Contains(strings.ToLower(as.GetString("banks")), strings.ToLower(Citi.String())),
UCCU: strings.Contains(strings.ToLower(as.GetString("banks")), strings.ToLower(UCCU.String())),
},
}
if config.Uploader == UploaderTodo {
token := as.GetString("todotoken") token := as.GetString("todotoken")
if len(token) == 0 { if len(token) == 0 {
token = getToken(as)
}
list := as.GetString("todolist")
if len(list) == 0 {
list = getList(as, token)
}
config.TodoToken = token
config.TodoList = list
}
return config
}
func getToken(as *args.ArgSet) string {
c := &http.Client{CheckRedirect: func(r *http.Request, via []*http.Request) error { c := &http.Client{CheckRedirect: func(r *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse return http.ErrUseLastResponse
}} }}
body := "username=" + as.GetString("todopass") body := "username=" + as.GetString("todopass")
req, err := http.NewRequest("POST", as.GetString("authaddr")+"/authorize/todo-server?"+oauth2.REDIRECT+"=127.0.0.1", strings.NewReader(body)) name := strings.Split(as.GetString("todoaddr"), ".")[0]
name = strings.TrimPrefix(name, "http://")
name = strings.TrimPrefix(name, "https://")
req, err := http.NewRequest("POST", as.GetString("authaddr")+"/authorize/"+name+"?"+oauth2.REDIRECT+"=127.0.0.1", strings.NewReader(body))
if err != nil { if err != nil {
panic(err) panic(err)
} }
@@ -60,12 +132,19 @@ func NewConfig() Config {
panic(err) panic(err)
} }
defer resp.Body.Close() defer resp.Body.Close()
cookie := resp.Header.Get("Set-Cookie") if resp.StatusCode > 399 {
token = cookie[strings.Index(cookie, "=")+1:] panic("bad status getting token: " + resp.Status)
token = strings.Split(token, "; ")[0]
} }
list := as.GetString("todolist") cookie := resp.Header.Get("Set-Cookie")
if len(list) == 0 { token := cookie[strings.Index(cookie, "=")+1:]
token = strings.Split(token, "; ")[0]
if len(token) == 0 {
panic(fmt.Sprintf("no token found: (%v) %v", resp.StatusCode, resp.Header))
}
return token
}
func getList(as *args.ArgSet, token string) string {
req, err := http.NewRequest("GET", as.GetString("todoaddr")+"/ajax.php?loadLists", nil) req, err := http.NewRequest("GET", as.GetString("todoaddr")+"/ajax.php?loadLists", nil)
if err != nil { if err != nil {
panic(err) panic(err)
@@ -81,29 +160,19 @@ func NewConfig() Config {
ID string `json:"id"` ID string `json:"id"`
} `json:"list"` } `json:"list"`
} }
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { b, err := ioutil.ReadAll(resp.Body)
if err != nil {
panic(err) panic(err)
} }
if err := json.Unmarshal(b, &r); err != nil {
panic(fmt.Errorf("%v: %s", err, b))
}
if len(r.List) == 0 { if len(r.List) == 0 {
panic("no lists found") panic("no lists found")
} }
list = r.List[0].ID list := r.List[0].ID
if len(list) == 0 {
panic("empty list found")
} }
return list
storage, err := storage.New(storage.TypeFromString(as.GetString("store")), as.GetString("storeaddr"), as.GetString("storeuser"), as.GetString("storepass"))
if err != nil {
panic(err)
}
config = Config{
EmailUser: as.GetString("emailuser"),
EmailPass: as.GetString("emailpass"),
EmailIMAP: as.GetString("emailimap"),
TodoAddr: as.GetString("todoaddr"),
TodoToken: token,
TodoList: list,
TodoTag: as.GetString("todotag"),
Storage: storage,
}
return config
} }

13
main.go Normal file → Executable file
View File

@@ -3,6 +3,7 @@ package main
import ( import (
"local/sandbox/contact/contact" "local/sandbox/contact/contact"
"log" "log"
"regexp"
) )
func main() { func main() {
@@ -16,12 +17,22 @@ func main() {
if err != nil { if err != nil {
panic(err) panic(err)
} }
patterns := regexp.MustCompile(config.AccountsPattern)
antipatterns := regexp.MustCompile(config.AccountsAntiPattern)
for email := range emails { for email := range emails {
transactions, err := Scrape(email) transactions, err := Scrape(email, config.Banks)
if err != nil { if err != nil {
log.Println("failed to scrape email:", err) log.Println("failed to scrape email:", err)
} }
for _, transaction := range transactions { for _, transaction := range transactions {
if !patterns.MatchString(transaction.Account) {
log.Printf("skipping unmatching account pattern %q vs %q", config.AccountsPattern, transaction.Account)
continue
}
if antipatterns.MatchString(transaction.Account) {
log.Printf("skipping match account antipattern %q vs %q", config.AccountsAntiPattern, transaction.Account)
continue
}
if _, err := config.Storage.Get(transaction.ID); err == nil { if _, err := config.Storage.Get(transaction.ID); err == nil {
log.Println("skipping duplicate transaction:", transaction) log.Println("skipping duplicate transaction:", transaction)
} else { } else {

151
scrape.go Normal file → Executable file
View File

@@ -1,11 +1,13 @@
package main package main
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net/mail" "net/mail"
"regexp" "regexp"
"strconv"
"strings" "strings"
) )
@@ -15,27 +17,31 @@ type scraper interface {
type chaseScraper struct{} type chaseScraper struct{}
type citiScraper struct{} type citiScraper struct{}
type uccuScraper struct{}
func Scrape(m *mail.Message) ([]*Transaction, error) { func Scrape(m *mail.Message, banks map[Bank]bool) ([]*Transaction, error) {
scraper, err := buildScraper(m) scraper, err := buildScraper(m, banks)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return scraper.scrape(m) return scraper.scrape(m)
} }
func buildScraper(m *mail.Message) (scraper, error) { func buildScraper(m *mail.Message, banks map[Bank]bool) (scraper, error) {
subject := fmt.Sprint(m.Header["Subject"]) subject := fmt.Sprint(m.Header["Subject"])
if !containsAny(subject, "transaction", "report", "Transaction") { if !containsAny(subject, "transaction", "report", "Transaction") {
return nil, errors.New("cannot build scraper for subject " + subject) return nil, errors.New("cannot build scraper for subject " + subject)
} }
from := fmt.Sprint(m.Header["From"]) from := fmt.Sprint(m.Header["From"])
if strings.Contains(from, "Chase") { if strings.Contains(from, "Chase") && banks[Chase] {
return newChaseScraper(), nil return newChaseScraper(), nil
} }
if strings.Contains(from, "Citi") { if strings.Contains(from, "Citi") && banks[Citi] {
return newCitiScraper(), nil return newCitiScraper(), nil
} }
if strings.Contains(from, "Notifications@uccu.com") && banks[UCCU] {
return newUCCUScraper(), nil
}
return nil, errors.New("unknown sender: " + from) return nil, errors.New("unknown sender: " + from)
} }
@@ -43,6 +49,10 @@ func newChaseScraper() scraper {
return &chaseScraper{} return &chaseScraper{}
} }
func newUCCUScraper() scraper {
return &uccuScraper{}
}
func newCitiScraper() scraper { func newCitiScraper() scraper {
return &citiScraper{} return &citiScraper{}
} }
@@ -57,31 +67,142 @@ func containsAny(a string, b ...string) bool {
} }
func (c *chaseScraper) scrape(m *mail.Message) ([]*Transaction, error) { func (c *chaseScraper) scrape(m *mail.Message) ([]*Transaction, error) {
transactions, err := c.scrape2021(m)
if err == nil && len(transactions) > 0 {
return transactions, err
}
return c.scrape2020(m)
}
func (c *chaseScraper) scrape2021(m *mail.Message) ([]*Transaction, error) {
re := regexp.MustCompile(`^Your \$(?P<amount>[0-9\.]*) transaction with (?P<vendor>.*)$`)
matches := re.FindSubmatch([]byte(m.Header["Subject"][0]))
if len(matches) < 1 {
return nil, errors.New("no match subject search")
}
amount := string(matches[1])
vendor := string(matches[2])
b, _ := ioutil.ReadAll(m.Body)
re = regexp.MustCompile(`\(\.\.\.[0-9]{4}\)`)
match := re.Find(b)
re = regexp.MustCompile(`[0-9]{4}`)
account := string(re.Find(match))
return []*Transaction{NewTransaction(account, amount, vendor, fmt.Sprint(m.Header["Date"]), Chase)}, nil
}
func (c *chaseScraper) scrape2020(m *mail.Message) ([]*Transaction, error) {
b, err := ioutil.ReadAll(m.Body) b, err := ioutil.ReadAll(m.Body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
regexp := regexp.MustCompile(`A charge of \([^)]*\) (?P<amount>[\d\.]+) at (?P<account>.*) has been authorized`) re := regexp.MustCompile(`A charge of \([^)]*\) (?P<amount>[\d\.]+) at (?P<vendor>.*) has been authorized`)
matches := regexp.FindSubmatch(b) matches := re.FindSubmatch(b)
if len(matches) < 2 { if len(matches) < 2 {
return nil, fmt.Errorf("no matches found: %+v: %s", matches, b) return nil, fmt.Errorf("no full matches found")
} }
results := make(map[string][]string) results := make(map[string][]string)
for i, name := range re.SubexpNames() {
if i != 0 && name != "" {
results[name] = append(results[name], string(matches[i]))
}
}
if len(results) != 2 || len(results["amount"]) != len(results["vendor"]) {
return nil, fmt.Errorf("unexpected matches found looking for transactions: %+v", results)
}
re = regexp.MustCompile(`account ending in (?P<account>[0-9]{4})\.`)
match := re.Find(b)
re = regexp.MustCompile(`[0-9]{4}`)
account := string(re.Find(match))
transactions := make([]*Transaction, len(results["amount"]))
for i := range results["amount"] {
transactions[i] = NewTransaction(account, results["amount"][i], results["vendor"][i], fmt.Sprint(m.Header["Date"]), Chase)
}
return transactions, nil
}
func (c *citiScraper) scrape(m *mail.Message) ([]*Transaction, error) {
date := fmt.Sprint(m.Header["Date"])
b, err := ioutil.ReadAll(m.Body)
if err != nil {
return nil, err
}
re := regexp.MustCompile(`Citi Alert: A \$[0-9][0-9]*\.[0-9][0-9] transaction was made at .* on card ending in`)
match := re.Find(b)
if len(match) == 0 {
return nil, nil
}
rePrice := regexp.MustCompile(`[0-9][0-9]*\.[0-9][0-9]`)
price := rePrice.Find(match)
vendor := bytes.Split(bytes.Split(match, []byte(" on card ending in"))[0], []byte("transaction was made at "))[1]
transaction := NewTransaction(Citi.String(), string(price), string(vendor), date, Citi)
return []*Transaction{transaction}, nil
//Citi Alert: A $598.14 transaction was made at REMIX MUSIC SPRINGDA on card ending in 3837
/*
b, err := ioutil.ReadAll(m.Body)
if err != nil {
return nil, err
}
targetLineRegexp := regexp.MustCompile(`Account #: XXXX[0-9]{4} .*`)
targetMatches := targetLineRegexp.FindAll(b, -1)
if len(targetMatches) == 0 {
return nil, errors.New("no lines with transactions found")
}
results := make(map[string][]string)
for _, b := range targetMatches {
// Account #: XXXX3837 $137.87 at AMZN Mktp US Amzn.com/bill WA on 04/03/2020, 09:05 PM ET
regexp := regexp.MustCompile(`Account #: XXXX[0-9]{4} \$(?P<amount>[0-9]+\.[0-9]*) at (?P<account>[^,]*)`)
matches := regexp.FindSubmatch(b)
if len(matches) < 2 {
return nil, fmt.Errorf("no full matches found: %s", b)
}
for i, name := range regexp.SubexpNames() { for i, name := range regexp.SubexpNames() {
if i != 0 && name != "" { if i != 0 && name != "" {
if name == "account" {
matches[i] = bytes.Split(matches[i], []byte(" on "))[0]
}
results[name] = append(results[name], string(matches[i])) results[name] = append(results[name], string(matches[i]))
} }
} }
if len(results) != 2 || len(results["amount"]) != len(results["account"]) { if len(results) != 2 || len(results["amount"]) != len(results["account"]) {
return nil, fmt.Errorf("unexpected matches found looking for transactions: %+v", results) return nil, fmt.Errorf("unexpected matches found looking for transactions: %+v", results)
} }
transactions := make([]*Transaction, len(results["amount"]))
for i := range results["amount"] {
transactions[i] = NewTransaction(results["amount"][i], results["account"][i], fmt.Sprint(m.Header["Date"]), Chase)
}
return transactions, nil
} }
func (c *citiScraper) scrape(m *mail.Message) ([]*Transaction, error) { transactions := make([]*Transaction, len(results["amount"]))
panic("not impl") for i := range results["amount"] {
transactions[i] = NewTransaction(Citi.String(), results["amount"][i], results["account"][i], fmt.Sprint(m.Header["Date"]), Citi)
}
return transactions, nil
*/
}
func (c *uccuScraper) scrape(m *mail.Message) ([]*Transaction, error) {
b, err := ioutil.ReadAll(m.Body)
if err != nil {
return nil, err
}
regexp := regexp.MustCompile(`\$([0-9]+,?)+\.[0-9][0-9]`)
match := regexp.Find(b)
if len(match) == 0 {
return nil, fmt.Errorf("no matches found")
}
match = match[1:]
match = bytes.ReplaceAll(match, []byte(","), []byte{})
f, err := strconv.ParseFloat(string(match), 10)
if err != nil {
return nil, err
}
if !bytes.Contains(b, []byte("credit")) {
f *= -1.0
}
transaction := NewTransaction(UCCU.String(), fmt.Sprintf("%.2f", f), "?", fmt.Sprint(m.Header["Date"]), UCCU)
return []*Transaction{transaction}, nil
} }

75
scrape_test.go Normal file
View File

@@ -0,0 +1,75 @@
package main
import (
"bytes"
"io/ioutil"
"net/mail"
"testing"
)
func TestScrapeChase2021(t *testing.T) {
b, err := ioutil.ReadFile("./testdata/chase.2021.txt")
if err != nil {
t.Fatal(err)
}
message := &mail.Message{
Header: map[string][]string{
"Subject": []string{"Your $38.84 transaction with TARGET T-1754"},
},
Body: bytes.NewReader(b),
}
chase := &chaseScraper{}
gots, err := chase.scrape2021(message)
if err != nil {
t.Fatal(err)
}
if len(gots) != 1 {
t.Fatal(gots)
}
got := gots[0]
if got.Account != "8824" {
t.Fatalf("bad account: %v: %+v", got.Account, got)
}
if got.Amount != "38.84" {
t.Fatalf("bad amount: %v: %+v", got.Amount, got)
}
if got.Vendor != "TARGET T-1754" {
t.Fatalf("bad vendor: %v: %+v", got.Vendor, got)
}
t.Logf("%+v", got)
}
func TestScrapeChase2020(t *testing.T) {
b, err := ioutil.ReadFile("./testdata/chase.2020.txt")
if err != nil {
t.Fatal(err)
}
message := &mail.Message{
Body: bytes.NewReader(b),
}
chase := &chaseScraper{}
gots, err := chase.scrape2020(message)
if err != nil {
t.Fatal(err)
}
if len(gots) != 1 {
t.Fatal(gots)
}
got := gots[0]
if got.Account != "8824" {
t.Fatalf("bad account: %v: %+v", got.Account, got)
}
if got.Amount != "16.08" {
t.Fatalf("bad amount: %v: %+v", got.Amount, got)
}
if got.Vendor != "PAYPAL *BLIZZARDENT" {
t.Fatalf("bad vendor: %q: %+v", got.Vendor, got)
}
t.Logf("%+v", got)
}

10
testdata/chase.2020.txt vendored Normal file
View File

@@ -0,0 +1,10 @@
This is an Alert to help you manage your credit card account ending in 8824.
As you requested, we are notifying you of any charges over the amount of ($USD) 0.00, as specified in your Alert settings.
A charge of ($USD) 16.08 at PAYPAL *BLIZZARDENT has been authorized on Jul 6, 2021 at 6:21 PM ET.
Do not reply to this Alert.
If you have questions, please call the number on the back of your credit card, or send a secure message from your Inbox on www.chase.com.
To see all of the Alerts available to you, or to manage your Alert settings, please log on to www.chase.com.

396
testdata/chase.2021.txt vendored Normal file
View File

@@ -0,0 +1,396 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.=
w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns=3D"http://www.w3.org/1999/xhtml" lang=3D"en">
<head>
<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3DUTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=3D1.=
0"/>
<title>This transaction is above the level you set, see more here.</title>
<style type=3D"text/css">
* {
=09line-height: normal !important;
}
strong {
=09font-weight: bold !important;
}
em {
=09font-style: italic !important;
}
body {
=09background-color: #d7dbe0 !important;
=09-webkit-text-size-adjust: none !important;
}
.ExternalClass * {
=09line-height: 112%
}
.ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass =
td {
=09line-height: 112%
}
td {
=09-webkit-text-size-adjust: none;
}
a[href^=3Dtel] {
=09color: inherit;
=09text-decoration: none;
}
.applelinksgray41 a {
=09color: #414042 !important;
=09text-decoration: none;
}
.applelinksgray a {
=09color: #717171 !important;
=09text-decoration: none;
}
.wordBreak {
=09overflow-wrap: break-word;
=09word-wrap: break-word;
=09word-break: break-all;
=09word-break: break-word;
}
@media screen and (max-width: 800px) {
.fullWidth {
=09width: 100% !important;
=09min-width: 100% !important;
=09margin-left: auto !important;
=09margin-right: auto !important;
=09padding: 0px !important;
=09text-align: center !important;
}
.moPad {
=09padding-right: 20px !important;
=09padding-left: 20px !important;
}
.zeroPad {
=09padding-right: 0px !important;
=09padding-left: 0px !important;
}
.font14 {
=09font-size: 14px !important;
}
}
@media print and (max-width: 800px) {
.fullWidth {
=09width: 100% !important;
=09min-width: 100% !important;
=09margin-left: auto !important;
=09margin-right: auto !important;
=09padding: 0px !important;
=09text-align: center !important;
}
.moPad {
=09padding-right: 20px !important;
=09padding-left: 20px !important;
}
.zeroPad {
=09padding-right: 0px !important;
=09padding-left: 0px !important;
}
.font14 {
=09font-size: 14px !important;
}
}
</style>
</head>
<body style=3D"padding: 0px;margin: 0px; background-color:#d7dbe0;">
<table role=3D"presentation" align=3D"center" width=3D"100%" border=3D"0" c=
ellspacing=3D"0" cellpadding=3D"0" style=3D"min-width:800px; background-col=
or:#d7dbe0;" class=3D"fullWidth">
<tr>
<td align=3D"center" style=3D"vertical-align:top; padding:0px 0px 20px =
0px; min-width:800px; background-color:#d7dbe0;" class=3D"fullWidth"><table=
role=3D"presentation" align=3D"center" width=3D"800" cellpadding=3D"0" cel=
lspacing=3D"0" border=3D"0" class=3D"fullWidth" style=3D"background-color:#=
FFFFFF;">
<!-- Start of Content -->
<tr>
<td align=3D"center" style=3D"vertical-align:top; padding: 23px 0=
px 0px;background-color: #005EB8;"><table role=3D"presentation" cellpadding=
=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"right" style=3D"vertical-align:bottom; padding=
:0px 0px; width:12px;"><img src=3D"https://www.chase.com/content/dam/email/=
images/blue-left.jpg" width=3D"12" height=3D"226" border=3D"0" style=3D"dis=
play:block;" alt=3D""/></td>
<td align=3D"center" style=3D"vertical-align:bottom; paddin=
g: 0px 0px 0px;width:616px; background-color: #FFFFFF;"><table role=3D"pres=
entation" width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-align:top; paddi=
ng: 0px 0px;"><table role=3D"presentation" width=3D"100%" cellpadding=3D"0"=
cellspacing=3D"0" border=3D"0">
<!-- Start hidden preview text -->
<div style=3D"display: none; max-height: 0px; ove=
rflow: hidden;">This transaction is above the level you set, see more here.=
</div>
<!-- Insert &zwnj;&nbsp; after hidden preview tex=
t -->
<div style=3D"display: none; max-height: 0px; ove=
rflow: hidden;"> &nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwn=
j;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&=
nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwn=
j;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&=
nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbs=
p;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&=
zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwn=
j;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&=
nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbs=
p;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&=
zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&nbsp;&zwnj;&nbs=
p;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&=
zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwn=
j;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&=
nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbs=
p;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&=
zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwn=
j;&nbsp;</div>
<!-- End hidden preview text -->
<tr>
<td align=3D"left" style=3D"vertical-align:top;=
padding-left: 30px;" class=3D"moPad"><table role=3D"presentation" width=3D"=
100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-alig=
n:bottom; padding:36px 0px 20px;"><img src=3D"https://www.chase.com/content=
/dam/email/images/chase-logo-h-rgb.png" width=3D"104" height=3D"20" border=
=3D"0" style=3D"display:block;" alt=3D"Chase Logo"/></td>
</tr>
</table></td>
</tr>
<tr>
<td align=3D"left" style=3D"vertical-align:top;=
padding: 20px 28px 0px;" class=3D"moPad"><table role=3D"presentation" alig=
n=3D"left" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-alig=
n:top;"><table role=3D"presentation" width=3D"100%" cellpadding=3D"0" cells=
pacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertica=
l-align:top; padding:5px 10px; font-family:Arial, Helvetica, sans-serif; fo=
nt-size:12px; font-weight:bold; color:#414042; background-color:#D7DBE0; bo=
rder-radius:20px; -moz-border-radius: 20px; -webkit-border-radius:20px; whi=
te-space: nowrap;" class=3D"font14">Transaction alert</td>
</tr>
</table></td>
</tr>
</table></td>
</tr>
<tr>
<td align=3D"left" style=3D"vertical-align:top;=
"><table role=3D"presentation" width=3D"100%" cellpadding=3D"0" cellspacing=
=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-alig=
n:top; padding: 20px 30px 28px;" class=3D"moPad"><table role=3D"presentatio=
n" width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertica=
l-align:top; padding: 0px 20px 0px 0px;"><img src=3D"https://static.chasecd=
n.com/content/services/rendition/image.small.png/unified-assets/digital-car=
ds/aarp/41473417018.png" width=3D"57" height=3D"auto" alt=3D"" border=3D"0"=
style=3D"display:block;"/></td>
<td align=3D"left" style=3D"vertica=
l-align:top; padding:0px 50px 0px 0px; font-family:Arial, Helvetica, sans-s=
erif; font-size:30px; font-weight: bold; color:#414042;" class=3D"zeroPad">=
You made a $38.84 transaction</td>
</tr>
</table></td>
</tr>
</table></td>
</tr>
</table></td>
</tr>
</table></td>
<td align=3D"left" style=3D"vertical-align:bottom; padding:=
0px 0px;width:12px; "><img src=3D"https://www.chase.com/content/dam/email/i=
mages/blue-right.jpg" width=3D"12" height=3D"226" border=3D"0" style=3D"dis=
play:block;" alt=3D""/></td>
</tr>
</table></td>
</tr>
<tr>
<td align=3D"center" style=3D"vertical-align:top; padding: 0px 0p=
x 0px; background-color: #FFFFFF;"><table role=3D"presentation" cellpadding=
=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"right" style=3D"vertical-align:top; padding:0p=
x 0px; width:12px;"><img src=3D"https://www.chase.com/content/dam/email/ima=
ges/white-left.jpg" width=3D"12" height=3D"77" border=3D"0" style=3D"displa=
y:block;" alt=3D""/></td>
<td align=3D"center" style=3D"vertical-align:top; padding: =
0px 0px 0px;width:616px;"><table role=3D"presentation" width=3D"100%" cellp=
adding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-align:top; paddi=
ng: 0px 150px 0px 30px;" class=3D"moPad"><table role=3D"presentation" width=
=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-align:top;=
padding: 10px 0px;border-bottom: solid 1px #414042;"><table role=3D"presen=
tation" width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-alig=
n:top; padding:0px 0px 0px 0px; font-family:Arial, Helvetica, sans-serif; f=
ont-size:16px; color:#414042;" class=3D"font14">Account</td>
<td align=3D"right" style=3D"vertical-ali=
gn:top; padding:0px 0px 0px 5px; font-family:Arial, Helvetica, sans-serif; =
font-size:16px; font-weight:bold; color:#414042;" class=3D"font14">AARP fro=
m Chase (...8824)</td>
</tr>
</table></td>
</tr>
<tr>
<td align=3D"left" style=3D"vertical-align:top;=
padding: 10px 0px;border-bottom: solid 1px #414042;"><table role=3D"presen=
tation" width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-alig=
n:top; padding:0px 0px 0px 0px; font-family:Arial, Helvetica, sans-serif; f=
ont-size:16px; color:#414042;" class=3D"font14">Date</td>
<td align=3D"right" style=3D"vertical-ali=
gn:top; padding:0px 0px 0px 5px; font-family:Arial, Helvetica, sans-serif; =
font-size:16px; font-weight:bold; color:#414042;" class=3D"font14"><span cl=
ass=3D"applelinksgray41"><a style=3D"color:#414042;text-decoration: none;">=
Jul 23, 2021 at 3:06 PM ET</td>
</tr>
</table></td>
</tr>
<tr>
<td align=3D"left" style=3D"vertical-align:top;=
padding: 10px 0px;border-bottom: solid 1px #414042;"><table role=3D"presen=
tation" width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-alig=
n:top; padding:0px 0px 0px 0px; font-family:Arial, Helvetica, sans-serif; f=
ont-size:16px; color:#414042;" class=3D"font14">Merchant</td>
<td align=3D"right" style=3D"vertical-ali=
gn:top; padding:0px 0px 0px 5px; font-family:Arial, Helvetica, sans-serif; =
font-size:16px; font-weight:bold; color:#414042;" class=3D"font14">TARGET T=
-1754</td>
</tr>
</table></td>
</tr>
<tr>
<td align=3D"left" style=3D"vertical-align:top;=
padding: 10px 0px;border-bottom: solid 1px #414042;"><table role=3D"presen=
tation" width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-alig=
n:top; padding:0px 0px 0px 0px; font-family:Arial, Helvetica, sans-serif; f=
ont-size:16px; color:#414042;" class=3D"font14">Amount</td>
<td align=3D"right" style=3D"vertical-ali=
gn:top; padding:0px 0px 0px 5px; font-family:Arial, Helvetica, sans-serif; =
font-size:16px; font-weight:bold; color:#414042;" class=3D"font14">$38.84</=
td>
</tr>
</table></td>
</tr>
</table></td>
</tr>
<tr>
<td align=3D"left" style=3D"vertical-align:top; pad=
ding:40px 150px 40px 30px; font-family:Arial, Helvetica, sans-serif; font-s=
ize:16px; color:#414042;" class=3D"moPad">You are receiving this alert beca=
use your transaction was more than the $0.00 level you set. You can visit o=
ur <a style=3D"text-decoration: underline; color:#0060F0;" href=3D"https://=
www.chase.com/personal/credit-cards/card-resource-center" rel=3D"noopener n=
oreferrer" target=3D"_blank">Resource Center</a> anytime to help answer yo=
ur questions or manage your account.</td>
</tr>
<tr>
<td align=3D"left" style=3D"padding:0px; vertical-ali=
gn:top; padding: 0px 0px 30px 30px;" class=3D"moPad"><table role=3D"present=
ation" align=3D"left" cellpadding=3D"0" cellspacing=3D"0" border=3D"0" styl=
e=3D"vertical-align:top;">
<tr>
<td role=3D"button" align=3D"center" style=3D"b=
ackground-color:#0060f0; color: #fffffe; font-size: 16px; font-family: Aria=
l, Helvetica, sans-serif; padding: 10px 0px; border: 1px solid #0060f0; ver=
tical-align:top; border-radius:4px; -moz-border-radius: 4px; -webkit-border=
-radius:4px;width: 168px;"><a href=3D"https://secure.chase.com/web/auth/nav=
?navKey=3DrequestDashboard" target=3D"_blank" style=3D"color: #fffffe; text=
-decoration:none;">Review account</a></td>
</tr>
</table></td>
</tr>
<tr>
<td align=3D"left" style=3D"vertical-align:top; paddi=
ng:0px 30px 20px; font-family:Arial, Helvetica, sans-serif; font-size:12px;=
color:#717171;" class=3D"moPad font14">Securely access your accounts with =
the <a style=3D"text-decoration: underline; color:#0060F0;" href=3D"https:/=
/www.chase.com/digital/mobile-banking" rel=3D"noopener noreferrer" target=
=3D"_blank">Chase&nbsp;Mobile<span style=3D"font-size:70%; line-height:0; v=
ertical-align:3px; text-decoration: none;">&reg;</span> app</a> or <a style=
=3D"text-decoration: underline; color:#0060F0;" href=3D"https://secure.chas=
e.com/web/auth/nav?navKey=3DrequestDashboard" rel=3D"noopener noreferrer" =
target=3D"_blank">chase.com</a>. </td>
</tr>
<tr>
<td align=3D"left" style=3D"vertical-align:top; paddi=
ng: 0px 0px; background-color: #F6F6F6;"><table role=3D"presentation" width=
=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-align:top;=
padding:20px 30px 60px; font-family:Arial, Helvetica, sans-serif; font-siz=
e:12px; color:#717171;" class=3D"moPad font14"><span role=3D"heading" style=
=3D"text-transform: uppercase; font-weight: bold;">About this message</span=
><br />
<br />
Chase&nbsp;Mobile<span style=3D"font-size:70%=
; line-height:0; vertical-align:3px;">&reg;</span>=C2=A0app is available f=
or select mobile devices. Message and data rates may apply.<br />
<br />
This service email was sent based on your ale=
rt settings. Use the Chase&nbsp;Mobile app or visit <a href=3D"https://www.=
chase.com/personal/mobile-online-banking/login-alerts" target=3D"_blank" st=
yle=3D"text-decoration: underline; color:#0060F0;" rel=3D"noopener noreferr=
er">chase.com/alerts</a> to view or manage your settings.<br />
<br />
Chase cannot guarantee the delivery of alerts=
and notifications.=C2=A0Wireless or internet service provider outages or o=
ther circumstances could delay them. You can always check <span class=3D"ap=
plelinksgray"><a style=3D"color:#717171;text-decoration: none;">chase.com</=
a></span> or the Chase&nbsp;Mobile=C2=A0app for the status of your accounts=
including your latest account balances and transaction details.=C2=A0<br /=
>
<br />
To protect your personal information, please =
don't reply to this message. Chase won't ask for confidential information i=
n an email. <br />
<br />
If you have concerns about the authenticity o=
f this message or have questions about your account visit <a style=3D"text-=
decoration: underline; color:#0060F0;" href=3D"https://www.chase.com/digita=
l/customer-service" target=3D"_blank" rel=3D"noopener noreferrer">chase.com=
/CustomerService</a> for ways to contact us.<br />
<br />
Your privacy is important to us. See our onli=
ne <a style=3D"text-decoration: underline; color:#0060F0;" href=3D"https://=
www.chase.com/digital/resources/privacy-security" target=3D"_blank" rel=3D"=
noopener noreferrer">Security Center</a> to learn how to protect your infor=
mation.<br />
<br />
&copy; 2021 JPMorgan Chase &amp; Co. </td>
</tr>
</table></td>
</tr>
</table></td>
<td align=3D"left" style=3D"vertical-align:top; padding:0px=
0px; width:12px;"><img src=3D"https://www.chase.com/content/dam/email/imag=
es/white-right.jpg" width=3D"12" height=3D"77" border=3D"0" style=3D"displa=
y:block;" alt=3D""/></td>
</tr>
</table></td>
</tr>
<!--End of Content -->
=20
</table></td>
</tr>
</table>
</body>
</html>

31
transaction.go Normal file → Executable file
View File

@@ -3,27 +3,52 @@ package main
import ( import (
"crypto/md5" "crypto/md5"
"fmt" "fmt"
"log"
"regexp"
"time"
) )
type Transaction struct { type Transaction struct {
ID string ID string
Bank Bank Bank Bank
Amount string Amount string
Account string Vendor string
Date string Date string
Account string
}
func (t *Transaction) Format() string {
return fmt.Sprintf("(%s) %v/%v: %s @ %s", cleanDate(t.Date), t.Account, t.Bank, t.Amount, t.Vendor)
} }
func (t *Transaction) String() string { func (t *Transaction) String() string {
return fmt.Sprint(*t) return fmt.Sprint(*t)
} }
func NewTransaction(amount, account, date string, bank Bank) *Transaction { func NewTransaction(account, amount, vendor, date string, bank Bank) *Transaction {
regexp := regexp.MustCompile(`\s\s+`)
t := &Transaction{ t := &Transaction{
Amount: amount,
Account: account, Account: account,
Amount: regexp.ReplaceAllString(amount, " "),
Vendor: regexp.ReplaceAllString(vendor, " "),
Bank: bank, Bank: bank,
Date: date, Date: date,
} }
t.ID = fmt.Sprintf("%x", md5.Sum([]byte(fmt.Sprint(t)))) t.ID = fmt.Sprintf("%x", md5.Sum([]byte(fmt.Sprint(t))))
return t return t
} }
func cleanDate(date string) string {
regexp := regexp.MustCompile(`[A-Z][a-z]{2}, [0-9][0-9]? [A-Z][a-z]{2} 2[0-9]{3}`)
matches := regexp.FindAllString(date, -1)
if len(matches) < 1 {
return date
}
date = matches[0]
time, err := time.Parse(`Mon, 2 Jan 2006`, date)
if err != nil {
log.Println(err)
return date
}
return time.Format("Mon Jan 2")
}

9
transaction_test.go Executable file
View File

@@ -0,0 +1,9 @@
package main
import "testing"
func TestTransactionFormat(t *testing.T) {
x := NewTransaction("me", "12.34", "Amazon", "[Wed, 1 Apr 2020 10:14:11 -0400 (EDT)]", Chase)
t.Logf("%s", x.String())
t.Logf("%s", x.Format())
}

53
upload.go Normal file → Executable file
View File

@@ -1,18 +1,34 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"local/oauth2" "local/oauth2"
"net/http" "net/http"
"net/url" "net/url"
"os"
"regexp"
"strconv"
"strings" "strings"
"time"
) )
func Upload(config Config, transaction *Transaction) error { func Upload(config Config, transaction *Transaction) error {
switch config.Uploader {
case UploaderTodo:
return uploadTodo(config, transaction)
case UploaderLedger:
return uploadLedger(config, transaction)
default:
return errors.New("not impl: uploader")
}
}
func uploadTodo(config Config, transaction *Transaction) error {
params := url.Values{ params := url.Values{
"list": {config.TodoList}, "list": {config.TodoList},
"title": {fmt.Sprintf("%v: %s @ %s @ %s", transaction.Bank, transaction.Amount, transaction.Account, transaction.Date)}, "title": {transaction.Format()},
"tag": {config.TodoTag}, "tag": {config.TodoTag},
} }
req, err := http.NewRequest("POST", config.TodoAddr+"/ajax.php?newTask", strings.NewReader(params.Encode())) req, err := http.NewRequest("POST", config.TodoAddr+"/ajax.php?newTask", strings.NewReader(params.Encode()))
@@ -32,3 +48,38 @@ func Upload(config Config, transaction *Transaction) error {
} }
return nil return nil
} }
func uploadLedger(config Config, transaction *Transaction) error {
f, err := os.OpenFile(config.TodoAddr, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
amount, _ := strconv.ParseFloat(transaction.Amount, 32)
amount *= -1
remote := "Withdrawal:"
if amount > 0 {
remote = "Deposit:"
}
regexp := regexp.MustCompile(`[0-9a-zA-Z]`)
for _, substr := range regexp.FindAllString(transaction.Vendor, -1) {
remote += substr
}
fmt.Fprintf(f, "%-50s%-s\n", formatGMailDate(transaction.Date), transaction.Vendor)
fmt.Fprintf(f, "%-50s%-50s$%.2f\n", "", "AssetAccount:"+transaction.Bank.String()+":"+transaction.Account, amount)
fmt.Fprintf(f, "%-50s%-s\n", "", remote)
return nil
}
func formatGMailDate(s string) string {
for _, format := range []string{
"[Mon, 2 Jan 2006 15:04:05 -0700 (MST)]",
"[Mon, 2 Jan 2006 15:04:05 -0700]",
} {
time, err := time.Parse(format, s)
if err == nil {
return time.Format("2006-01-02")
}
}
return s
}

75
upload_test.go Normal file
View File

@@ -0,0 +1,75 @@
package main
import (
"bytes"
"io/ioutil"
"local/storage"
"path"
"testing"
"github.com/google/uuid"
)
func TestUploadLedger(t *testing.T) {
cases := map[string]struct {
transaction Transaction
}{
"simple": {
transaction: Transaction{
ID: uuid.New().String(),
Bank: Chase,
Amount: "1.10",
Vendor: "my's totally realistic!!! VENDOR 11",
Date: "today",
},
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
config := Config{
TodoAddr: path.Join(t.TempDir(), name),
Uploader: UploaderLedger,
Storage: storage.NewMap(),
}
if err := uploadLedger(config, &c.transaction); err != nil {
t.Fatal(err)
}
b, err := ioutil.ReadFile(config.TodoAddr)
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(b, []byte("mystotallyrealisticVENDOR11")) {
t.Fatal(string(b))
}
t.Logf("\n%s", b)
})
}
}
func TestFormatGMailDate(t *testing.T) {
cases := map[string]struct {
input string
want string
}{
"ok": {
input: "[Tue, 20 Jul 2021 23:46:00 -0400 (EDT)]",
want: "2021-07-20",
},
"bad": {
input: "2021-07-20",
want: "2021-07-20",
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
got := formatGMailDate(c.input)
if got != c.want {
t.Fatal(c.input, c.want, got)
}
})
}
}