From 6c9969dba3a4277c5ab5d68786c225d19c215703 Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Tue, 11 Feb 2025 17:14:23 +0800 Subject: [PATCH 01/19] Add send with plain style Signed-off-by: Toomore Chiang --- cmd/send.go | 19 ++++++++++++++++--- cmd/user.go | 2 +- mails/mails.go | 25 ++++++++++++++++++++++--- mails/mails_test.go | 4 +++- 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/cmd/send.go b/cmd/send.go index fa67d25..7dbfe77 100644 --- a/cmd/send.go +++ b/cmd/send.go @@ -23,7 +23,7 @@ package cmd import ( "database/sql" "fmt" - "io/ioutil" + "io" "log" "os" "strings" @@ -39,6 +39,7 @@ var ( sendGroups *string sendLimit *int sendPath *string + sendTextPath *string sendReplaceLink *bool sendSubject *string sendUID *string @@ -60,10 +61,21 @@ var sendCmd = &cobra.Command{ if err != nil { log.Fatal("[cmd][send][open] ", err) } - body, err := ioutil.ReadAll(file) + + file_text, err := os.Open(*sendTextPath) + if err != nil { + log.Fatal("[cmd][send][open] ", err) + } + + body, err := io.ReadAll(file) if err != nil { log.Fatal("[cmd][send][ReadAll] ", err) } + body_text, err := io.ReadAll(file_text) + if err != nil { + log.Fatal("[cmd][send][ReadAll] ", err) + } + var rows *sql.Rows if *sendUID != "" { uids := strings.Split(*sendUID, ",") @@ -79,7 +91,7 @@ var sendCmd = &cobra.Command{ log.Fatal("[cmd][send][Query] ", err) } - mails.ProcessSend(body, rows, *sendCID, *sendReplaceLink, *sendSubject, *sendDryRun, *sendLimit) + mails.ProcessSend(body, body_text, rows, *sendCID, *sendReplaceLink, *sendSubject, *sendDryRun, *sendLimit) }, } @@ -89,6 +101,7 @@ func init() { sendDryRun = sendCmd.Flags().BoolP("dryrun", "d", false, "Dry run") sendGroups = sendCmd.Flags().StringP("groups", "g", "", "User groups") sendPath = sendCmd.Flags().StringP("path", "p", "", "HTML file path") + sendTextPath = sendCmd.Flags().StringP("text", "t", "", "Plain file path") sendReplaceLink = sendCmd.Flags().Bool("rl", true, "Replace A tag links") sendSubject = sendCmd.Flags().StringP("subject", "s", "", "Mail subject") sendLimit = sendCmd.Flags().IntP("limit", "", 7, "Send concurrency limit") diff --git a/cmd/user.go b/cmd/user.go index c1a8f95..e0ead7e 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -144,10 +144,10 @@ func updateUser(data []user) { func readUser(group string) { rows, err := userConn.Query(`SELECT id,email,f_name,l_name,alive,created FROM user WHERE alive=1 AND groups=?`, group) - defer rows.Close() if err != nil { log.Fatal("[cmd][readUser][Query]", err) } + defer rows.Close() var ( id string email string diff --git a/mails/mails.go b/mails/mails.go index 4541717..42bf299 100644 --- a/mails/mails.go +++ b/mails/mails.go @@ -39,6 +39,10 @@ func baseParams() *ses.SendEmailInput { Data: aws.String(""), Charset: aws.String("UTF-8"), }, + Text: &ses.Content{ + Data: aws.String(""), + Charset: aws.String("UTF-8"), + }, }, Subject: &ses.Content{ Data: aws.String(""), @@ -50,10 +54,11 @@ func baseParams() *ses.SendEmailInput { } // GenParams is to gen email params -func GenParams(to string, message string, subject string) *ses.SendEmailInput { +func GenParams(to string, message string, subject string, text string) *ses.SendEmailInput { params := baseParams() params.Destination.ToAddresses[0] = aws.String(to) params.Message.Body.Html.Data = aws.String(message) + params.Message.Body.Text.Data = aws.String(text) params.Message.Subject.Data = aws.String(subject) if os.Getenv("mailbox_ses_replyto") != "" { params.ReplyToAddresses = []*string{aws.String(os.Getenv("mailbox_ses_replyto"))} @@ -81,7 +86,7 @@ func SendWG(params *ses.SendEmailInput, wg *sync.WaitGroup) { } // ProcessSend is to start send from rows -func ProcessSend(body []byte, rows *sql.Rows, cid string, replaceLink bool, subject string, dryRun bool, limit int) { +func ProcessSend(body []byte, text []byte, rows *sql.Rows, cid string, replaceLink bool, subject string, dryRun bool, limit int) { var seed = campaign.GetSeed(cid) var count int @@ -95,6 +100,7 @@ func ProcessSend(body []byte, rows *sql.Rows, cid string, replaceLink bool, subj fname string lname string msg []byte + msg_text []byte no string subjectbyte []byte ) @@ -102,22 +108,33 @@ func ProcessSend(body []byte, rows *sql.Rows, cid string, replaceLink bool, subj rows.Scan(&no, &email, &fname, &lname) msg = body + msg_text = text if replaceLink { allATags = FilterATags(&msg, cid) ReplaceATag(&msg, allATags, cid, seed, no) allWashiTags = FilterWashiTags(&msg, cid) ReplaceWashiTag(&msg, allWashiTags, cid, seed, no) + + allATags = FilterATags(&msg_text, cid) + ReplaceATag(&msg_text, allATags, cid, seed, no) + allWashiTags = FilterWashiTags(&msg_text, cid) + ReplaceWashiTag(&msg_text, allWashiTags, cid, seed, no) } ReplaceFname(&msg, fname) ReplaceLname(&msg, lname) ReplaceReader(&msg, cid, seed, no) + ReplaceFname(&msg_text, fname) + ReplaceLname(&msg_text, lname) + ReplaceReader(&msg_text, cid, seed, no) + subjectbyte = []byte(subject) ReplaceFname(&subjectbyte, fname) ReplaceLname(&subjectbyte, lname) if dryRun { log.Printf("%s\n", msg) + log.Printf("%s\n", msg_text) var n int for _, v := range allATags { n++ @@ -134,7 +151,9 @@ func ProcessSend(body []byte, rows *sql.Rows, cid string, replaceLink bool, subj go SendWG(GenParams( fmt.Sprintf("%s %s <%s>", fname, lname, email), string(msg), - string(subjectbyte)), &wg) + string(subjectbyte), + string(msg_text), + ), &wg) } count++ } diff --git a/mails/mails_test.go b/mails/mails_test.go index e06ebcf..2dc67bf 100644 --- a/mails/mails_test.go +++ b/mails/mails_test.go @@ -9,7 +9,7 @@ import ( ) func TestGenParams(t *testing.T) { - t.Logf("%+v", GenParams("toomore0929@gmail.com", "message", "[Test]")) + t.Logf("%+v", GenParams("toomore0929@gmail.com", "message", "[Test]", "text message")) } func TestProcessSend(t *testing.T) { @@ -28,6 +28,7 @@ func TestProcessSend(t *testing.T) { // Test with dry run ProcessSend( []byte(`12`), + []byte(` {{WASHI}}https://toomore.net/{{/WASHI}}`), rows, fmt.Sprintf("%x", cid), true, @@ -44,6 +45,7 @@ func TestProcessSend(t *testing.T) { // Test Run ProcessSend( []byte(`12`), + []byte(` {{WASHI}}https://toomore.net/{{/WASHI}}`), rows, fmt.Sprintf("%x", cid), true, From 79465d678e8a8b8712c57a0b2c64fe656a0f2be9 Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Tue, 11 Feb 2025 23:24:20 +0800 Subject: [PATCH 02/19] Add FormatEmail Signed-off-by: Toomore Chiang --- utils/utils.go | 15 +++++++++++++++ utils/utils_test.go | 16 ++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/utils/utils.go b/utils/utils.go index 62a2a9d..1955324 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -6,6 +6,8 @@ import ( "crypto/sha256" "database/sql" "log" + "regexp" + "strings" ) const ( @@ -41,3 +43,16 @@ func GetConn() *sql.DB { return conn } + +// FormatEmail is to make mail address unique +func FormatEmail(email string) string { + re := regexp.MustCompile(`\+[^@]*`) + email = re.ReplaceAllString(strings.TrimSpace(strings.ToLower(email)), "") + + parts := strings.Split(email, "@") + localPart, domain := parts[0], parts[1] + + localPart = strings.ReplaceAll(localPart, ".", "") + + return localPart + "@" + domain +} diff --git a/utils/utils_test.go b/utils/utils_test.go index d07372c..254d86c 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -29,11 +29,10 @@ func TestGenHmac(t *testing.T) { func TestGetConn(t *testing.T) { rows, err := GetConn().Query("select id from campaign;") - defer rows.Close() - if err != nil { t.Log(err) } + defer rows.Close() var id string for rows.Next() { rows.Scan(&id) @@ -41,6 +40,19 @@ func TestGetConn(t *testing.T) { } } +func TestFormatEmail(t *testing.T) { + simple := FormatEmail("toomore.chiang+123@gmail.com") + if simple != "toomorechiang@gmail.com" { + t.Fatal(simple) + } +} + +func BenchmarkFormatEmail(b *testing.B) { + for i := 0; i < b.N; i++ { + FormatEmail("toomore.chiang+123@gmail.com") + } +} + func BenchmarkGenSeed(b *testing.B) { for i := 0; i < b.N; i++ { GenSeed() From e191b5f9d2c5e7e7191d79fccde018a2f7b0da50 Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Tue, 11 Feb 2025 23:36:43 +0800 Subject: [PATCH 03/19] Lint Signed-off-by: Toomore Chiang --- sql/database.sql | 6 +-- sql/tables.sql | 129 +++++++++++++++++++++++------------------------ 2 files changed, 65 insertions(+), 70 deletions(-) diff --git a/sql/database.sql b/sql/database.sql index 3642752..f779d70 100644 --- a/sql/database.sql +++ b/sql/database.sql @@ -1,3 +1,3 @@ -CREATE DATABASE IF NOT EXISTS mailbox - CHARACTER SET = 'utf8' - COLLATE = 'utf8_general_ci'; +CREATE DATABASE IF NOT EXISTS mailbox character +SET = 'utf8' COLLATE = 'utf8_general_ci'; + diff --git a/sql/tables.sql b/sql/tables.sql index 6ed3c64..38e87fa 100644 --- a/sql/tables.sql +++ b/sql/tables.sql @@ -1,72 +1,67 @@ -CREATE TABLE IF NOT EXISTS campaign ( - id CHAR(8) NOT NULL UNIQUE, - seed CHAR(8) NOT NULL, - created DATETIME DEFAULT NOW(), - updated DATETIME DEFAULT NOW() ON UPDATE NOW() - ) CHARACTER SET 'utf8' - COLLATE 'utf8_icelandic_ci'; +CREATE TABLE IF NOT EXISTS campaign( + id char(8) NOT NULL UNIQUE, + seed char(8) NOT NULL, + created DATETIME DEFAULT NOW(), + updated DATETIME DEFAULT NOW() ON UPDATE NOW()) character +SET 'utf8' COLLATE 'utf8_icelandic_ci'; -CREATE TABLE IF NOT EXISTS user ( - id INT NOT NULL AUTO_INCREMENT, - email VARCHAR(64) NOT NULL, - groups VARCHAR(64) NOT NULL, - f_name VARCHAR(32) NOT NULL, - l_name VARCHAR(32) not NULL, - created DATETIME DEFAULT NOW(), - updated DATETIME DEFAULT NOW() ON UPDATE NOW(), - alive BOOL DEFAULT true, - PRIMARY KEY (id), - UNIQUE KEY email_groups (email, groups), - INDEX groups (groups ASC) - ) CHARACTER SET 'utf8' - COLLATE 'utf8_icelandic_ci'; +CREATE TABLE IF NOT EXISTS USER ( + id int NOT NULL AUTO_INCREMENT, + email varchar(64) NOT NULL, + groups VARCHAR(64) NOT NULL, + f_name varchar(32) NOT NULL, + l_name varchar(32) NOT NULL, + created DATETIME DEFAULT NOW(), + updated DATETIME DEFAULT NOW() ON UPDATE NOW(), + alive bool DEFAULT TRUE, + PRIMARY KEY (id), + UNIQUE KEY email_groups(email, GROUPS), + INDEX GROUPS (GROUPS ASC)) character +SET 'utf8' COLLATE 'utf8_icelandic_ci'; -CREATE TABLE IF NOT EXISTS reader ( - no INT NOT NULL AUTO_INCREMENT, - uid INT NOT NULL, - cid CHAR(8) NOT NULL, - ip CHAR(15) NOT NULL, - agent CHAR(255) NOT NULL, - created DATETIME DEFAULT NOW(), - PRIMARY KEY (no), - INDEX uid (uid ASC), - INDEX cid (cid ASC) - ) CHARACTER SET 'utf8' - COLLATE 'utf8_icelandic_ci'; +CREATE TABLE IF NOT EXISTS reader( + no INT NOT NULL AUTO_INCREMENT, + uid int NOT NULL, + cid char(8) NOT NULL, + ip char(15) NOT NULL, + agent char(255) NOT NULL, + created DATETIME DEFAULT NOW(), + PRIMARY KEY (NO), + INDEX uid(uid ASC), + INDEX cid(cid ASC)) character +SET 'utf8' COLLATE 'utf8_icelandic_ci'; -CREATE TABLE IF NOT EXISTS doors ( - no INT NOT NULL AUTO_INCREMENT, - uid INT NOT NULL, - cid CHAR(8) NOT NULL, - linkid CHAR(8) NOT NULL, - ip CHAR(15) NOT NULL, - agent CHAR(255) NOT NULL, - created DATETIME DEFAULT NOW(), - PRIMARY KEY (no), - INDEX uid (uid ASC), - INDEX linkid (linkid ASC), - INDEX cid (cid ASC) - ) CHARACTER SET 'utf8' - COLLATE 'utf8_icelandic_ci'; +CREATE TABLE IF NOT EXISTS doors( + no INT NOT NULL AUTO_INCREMENT, + uid int NOT NULL, + cid char(8) NOT NULL, + linkid char(8) NOT NULL, + ip char(15) NOT NULL, + agent char(255) NOT NULL, + created DATETIME DEFAULT NOW(), + PRIMARY KEY (NO), + INDEX uid(uid ASC), + INDEX linkid(linkid ASC), + INDEX cid(cid ASC)) character +SET 'utf8' COLLATE 'utf8_icelandic_ci'; -CREATE TABLE IF NOT EXISTS links ( - id CHAR(8) NOT NULL, - cid CHAR(8) NOT NULL, - url TEXT NOT NULL, - urlhash CHAR(32) NOT NULL, - created DATETIME DEFAULT NOW(), - UNIQUE KEY cid_urlhash (cid, urlhash), - INDEX cid (cid ASC) - ) CHARACTER SET 'utf8' - COLLATE 'utf8_icelandic_ci'; +CREATE TABLE IF NOT EXISTS links( + id char(8) NOT NULL, + cid char(8) NOT NULL, + url text NOT NULL, + urlhash char(32) NOT NULL, + created DATETIME DEFAULT NOW(), + UNIQUE KEY cid_urlhash(cid, urlhash), + INDEX cid(cid ASC)) character +SET 'utf8' COLLATE 'utf8_icelandic_ci'; + +CREATE TABLE IF NOT EXISTS vote( + no INT NOT NULL AUTO_INCREMENT, + id char(8) NOT NULL, + ip char(15) NOT NULL, + agent char(255) NOT NULL, + created DATETIME DEFAULT NOW(), + PRIMARY KEY (NO), + INDEX id(id ASC)) character +SET 'utf8' COLLATE 'utf8_icelandic_ci'; -CREATE TABLE IF NOT EXISTS vote ( - no INT NOT NULL AUTO_INCREMENT, - id CHAR(8) NOT NULL, - ip CHAR(15) NOT NULL, - agent CHAR(255) NOT NULL, - created DATETIME DEFAULT NOW(), - PRIMARY KEY (no), - INDEX id (id ASC) - ) CHARACTER SET 'utf8' - COLLATE 'utf8_icelandic_ci'; From ccd6e71399ef5ceff6d95f10ac63831a88922b62 Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Wed, 12 Feb 2025 00:02:37 +0800 Subject: [PATCH 04/19] Add email_uni field Signed-off-by: Toomore Chiang --- cmd/user.go | 57 ++++++++++++++++++++++++++------------------------ sql/tables.sql | 3 ++- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/cmd/user.go b/cmd/user.go index e0ead7e..8b0966d 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -41,11 +41,12 @@ var ( ) type user struct { - email string - groups string - fname string - lname string - alive int + email string + email_uni string + groups string + fname string + lname string + alive int } func readCSV(path string) []user { @@ -63,6 +64,7 @@ func readCSV(path string) []user { case "email": for di, dv := range data[1:] { result[di].email = strings.TrimSpace(dv[i]) + result[di].email_uni = utils.FormatEmail(dv[i]) } case "groups": for di, dv := range data[1:] { @@ -90,13 +92,13 @@ func readCSV(path string) []user { } func insertInto(data []user) { - stmt, err := userConn.Prepare(`INSERT INTO user(email,groups,f_name,l_name) - VALUES(?,?,?,?) ON DUPLICATE KEY UPDATE f_name=?, l_name=?`) + stmt, err := userConn.Prepare(`INSERT INTO user(email,email_uni,groups,f_name,l_name) + VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE f_name=?, l_name=?, email=?`) if err != nil { log.Fatal("[cmd][insertInto][Prepare]", err) } for _, v := range data { - if result, err := stmt.Exec(v.email, v.groups, v.fname, v.lname, v.fname, v.lname); err == nil { + if result, err := stmt.Exec(v.email, v.email_uni, v.groups, v.fname, v.lname, v.fname, v.lname, v.email); err == nil { insertID, _ := result.LastInsertId() rowAff, _ := result.RowsAffected() log.Println("LastInsertId", insertID, "RowsAffected", rowAff) @@ -108,7 +110,7 @@ func insertInto(data []user) { func updateUser(data []user) { for _, v := range data { - rows, err := userConn.Query(`SELECT count(*) AS c FROM user WHERE groups=? AND email=?`, v.groups, v.email) + rows, err := userConn.Query(`SELECT count(*) AS c FROM user WHERE groups=? AND email_uni=?`, v.groups, v.email_uni) if err != nil { log.Fatal("[cmd][updateUser][Prepare]", err) } @@ -117,55 +119,56 @@ func updateUser(data []user) { rows.Scan(&c) } if c > 0 { - if result, err := userConn.Exec(`UPDATE user SET f_name=?, l_name=?, alive=? WHERE groups=? AND email=?`, - v.fname, v.lname, v.alive, v.groups, v.email); err == nil { + if result, err := userConn.Exec(`UPDATE user SET f_name=?, l_name=?, alive=?, email=? WHERE groups=? AND email_uni=?`, + v.fname, v.lname, v.alive, v.email, v.groups, v.email_uni); err == nil { insertID, _ := result.LastInsertId() rowAff, _ := result.RowsAffected() - log.Println("[UPDATE] LastInsertId", insertID, "RowsAffected", rowAff, "email", v.email) + log.Println("[UPDATE] LastInsertId", insertID, "RowsAffected", rowAff, "email", v.email, "email_uni", v.email_uni) } else { log.Println("[Err]", err) } } else { if v.alive == 1 { - if result, err := userConn.Exec(`INSERT INTO user(email, groups, f_name, l_name, alive) VALUES(?,?,?,?,?)`, - v.email, v.groups, v.fname, v.lname, v.alive); err == nil { + if result, err := userConn.Exec(`INSERT INTO user(email, email_uni, groups, f_name, l_name, alive) VALUES(?,?,?,?,?,?)`, + v.email, v.email_uni, v.groups, v.fname, v.lname, v.alive); err == nil { insertID, _ := result.LastInsertId() rowAff, _ := result.RowsAffected() - log.Println("[INSERT] LastInsertId", insertID, "RowsAffected", rowAff, "email", v.email) + log.Println("[INSERT] LastInsertId", insertID, "RowsAffected", rowAff, "email", v.email, "email_uni", v.email_uni) } else { log.Println("[Err]", err) } } else { - log.Println("[No INSERT alive=0 ]", v.email) + log.Println("[No INSERT alive=0 ]", v.email, v.email_uni) } } } } func readUser(group string) { - rows, err := userConn.Query(`SELECT id,email,f_name,l_name,alive,created FROM user WHERE alive=1 AND groups=?`, group) + rows, err := userConn.Query(`SELECT id,email,email_uni,f_name,l_name,alive,created FROM user WHERE alive=1 AND groups=?`, group) if err != nil { log.Fatal("[cmd][readUser][Query]", err) } defer rows.Close() var ( - id string - email string - fname string - lname string - created time.Time - alive int + id string + email string + email_uni string + fname string + lname string + created time.Time + alive int ) w := tabwriter.NewWriter(os.Stdout, 0, 0, 0, ' ', tabwriter.AlignRight|tabwriter.Debug) - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", "id", "email", "fname", "lname", "alive", "created") + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", "id", "email", "email_uni", "fname", "lname", "alive", "created") for rows.Next() { - if err := rows.Scan(&id, &email, &fname, &lname, &alive, &created); err != nil { + if err := rows.Scan(&id, &email, &email_uni, &fname, &lname, &alive, &created); err != nil { log.Println(err) } else { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\n", id, email, fname, lname, alive, created) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\t%s\n", id, email, email_uni, fname, lname, alive, created) } } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", "id", "email", "fname", "lname", "alive", "created") + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", "id", "email", "email_uni", "fname", "lname", "alive", "created") w.Flush() } diff --git a/sql/tables.sql b/sql/tables.sql index 38e87fa..d9db97d 100644 --- a/sql/tables.sql +++ b/sql/tables.sql @@ -8,6 +8,7 @@ SET 'utf8' COLLATE 'utf8_icelandic_ci'; CREATE TABLE IF NOT EXISTS USER ( id int NOT NULL AUTO_INCREMENT, email varchar(64) NOT NULL, + email_uni varchar(64) NOT NULL, groups VARCHAR(64) NOT NULL, f_name varchar(32) NOT NULL, l_name varchar(32) NOT NULL, @@ -15,7 +16,7 @@ CREATE TABLE IF NOT EXISTS USER ( updated DATETIME DEFAULT NOW() ON UPDATE NOW(), alive bool DEFAULT TRUE, PRIMARY KEY (id), - UNIQUE KEY email_groups(email, GROUPS), + UNIQUE KEY email_groups(email_uni, GROUPS), INDEX GROUPS (GROUPS ASC)) character SET 'utf8' COLLATE 'utf8_icelandic_ci'; From 5055350ea81640af9104899ca965e73481e491ba Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Wed, 12 Feb 2025 10:37:33 +0800 Subject: [PATCH 05/19] Fixed email_uni in testing Signed-off-by: Toomore Chiang --- mails/mails_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mails/mails_test.go b/mails/mails_test.go index 2dc67bf..6937360 100644 --- a/mails/mails_test.go +++ b/mails/mails_test.go @@ -13,12 +13,12 @@ func TestGenParams(t *testing.T) { } func TestProcessSend(t *testing.T) { - stmt, err := utils.GetConn().Prepare(`INSERT INTO user(email,groups,f_name,l_name) + stmt, err := utils.GetConn().Prepare(`INSERT INTO user(email,email_uni,groups,f_name,l_name) VALUES(?,?,?,?) ON DUPLICATE KEY UPDATE f_name=?, l_name=?`) if err != nil { t.Fatal(err) } - stmt.Exec("toomore0929@gmail.com", "test", "Toomore", "Chiang", "Toomore", "Chiang") + stmt.Exec("toomore0929+123@gmail.com", "toomore0929@gmail.com", "test", "Toomore", "Chiang", "Toomore", "Chiang") rows, err := utils.GetConn().Query("select id,email,f_name,l_name from user where groups='test'") if err != nil { @@ -37,7 +37,7 @@ func TestProcessSend(t *testing.T) { 4) stmt.Exec("to", "test2", "Toomore", "Chiang", "Toomore", "Chiang") - rows, err = utils.GetConn().Query("select id,email,f_name,l_name from user where groups='test2'") + rows, err = utils.GetConn().Query("select id,email,email_uni,f_name,l_name from user where groups='test2'") if err != nil { t.Fatal(err) From 888a7ab1dc208d4bee0dc0ea116dcbc04558e607 Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Wed, 12 Feb 2025 10:48:43 +0800 Subject: [PATCH 06/19] List all tables in testing Signed-off-by: Toomore Chiang --- .github/workflows/go.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 4228749..b638091 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -33,6 +33,8 @@ jobs: ls -alh mysql -h MARIADB -uroot -pmailboxdbs < ./sql/database.sql mysql -h MARIADB -uroot -pmailboxdbs mailbox < ./sql/tables.sql + mysql -h MARIADB -uroot -pmailboxdbs -e "SHOW DATABASES;" + mysql -h MARIADB -uroot -pmailboxdbs -e "SHOW TABLES;" mailbox - name: Set up Go uses: actions/setup-go@v4 From b473725630a63613be68d445361e216306eb3f8a Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Wed, 12 Feb 2025 10:52:04 +0800 Subject: [PATCH 07/19] Fixed table name in upper Signed-off-by: Toomore Chiang --- sql/tables.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/tables.sql b/sql/tables.sql index d9db97d..82d2e3f 100644 --- a/sql/tables.sql +++ b/sql/tables.sql @@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS campaign( updated DATETIME DEFAULT NOW() ON UPDATE NOW()) character SET 'utf8' COLLATE 'utf8_icelandic_ci'; -CREATE TABLE IF NOT EXISTS USER ( +CREATE TABLE IF NOT EXISTS user ( id int NOT NULL AUTO_INCREMENT, email varchar(64) NOT NULL, email_uni varchar(64) NOT NULL, From 971e06b843e2de251f29c7f2bd9bebe1f780fe51 Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Wed, 12 Feb 2025 10:54:18 +0800 Subject: [PATCH 08/19] Fixed in column match Signed-off-by: Toomore Chiang --- mails/mails_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mails/mails_test.go b/mails/mails_test.go index 6937360..591fc24 100644 --- a/mails/mails_test.go +++ b/mails/mails_test.go @@ -14,7 +14,7 @@ func TestGenParams(t *testing.T) { func TestProcessSend(t *testing.T) { stmt, err := utils.GetConn().Prepare(`INSERT INTO user(email,email_uni,groups,f_name,l_name) - VALUES(?,?,?,?) ON DUPLICATE KEY UPDATE f_name=?, l_name=?`) + VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE f_name=?, l_name=?`) if err != nil { t.Fatal(err) } From a5ac9e3f6937b9e1e9477a634b855cea847e4694 Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Wed, 12 Feb 2025 11:00:18 +0800 Subject: [PATCH 09/19] Split in github action Signed-off-by: Toomore Chiang --- .github/workflows/go.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index b638091..bc6c67b 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -57,6 +57,8 @@ jobs: sh ./goclean.sh go test -v -bench=Bench -benchmem -run='Bench' ./mails go test -v -bench=Bench -benchmem -run='Bench' ./utils + - name: Build image + run: | sh ./build-base.sh sh ./build-min.sh docker images From b58b575af02a29fff7be92eec780e0b965d29980 Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Sun, 8 Feb 2026 02:30:26 +0800 Subject: [PATCH 10/19] refactor: apply Phase 1-3 refactoring plan Phase 1 - DB connection management: - Make GetConn() singleton with sync.Once - Lower MaxOpenConns from 1024 to 50, add MaxIdleConns(10) - Remove conn.Close() from mails.filteratags (was closing shared pool) Phase 2 - SQL security and concurrency: - Parameterize send uid query, validate with strconv.Atoi - Use Exec with placeholders in campaign.Create - Add serverlinksCacheMu RWMutex for serverlinksCache Phase 3 - Error handling: - FormatEmail: guard against invalid email (no @) to prevent panic - GetSeed: check rows != nil before defer rows.Close() Also: remove unused fmt import from cmd/send.go, add TestFormatEmailInvalid for edge case coverage Co-authored-by: Cursor --- CLAUDE.md | 202 +++++++++++++++++++++++++++++++++++++++++++ campaign/campaign.go | 11 ++- cmd/send.go | 16 +++- cmd/server.go | 16 +++- mails/replace.go | 1 - utils/utils.go | 33 ++++--- utils/utils_test.go | 8 ++ 7 files changed, 263 insertions(+), 24 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c67f7b3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,202 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 專案概述 + +Mailbox 是一個簡易電子報發送系統,使用 Golang 實作。主要功能包括:建立發送 campaign 資訊、匯入訂閱者資訊(群組標記)、發送 HTML 格式電子報、開信追蹤與連結點擊追蹤。系統設計為在 Docker 容器中運行。 + +## 核心架構 + +### 資料流與組件互動 + +1. **Campaign 管理流程**:使用者首先透過 `campaign.Create()` 建立 campaign,每個 campaign 都有唯一的 ID 和加密種子(seed)。種子用於產生 HMAC 簽章,確保追蹤連結的安全性。 + +2. **使用者管理流程**:透過 CSV 檔案匯入訂閱者資料到資料庫的 `user` 表。每個使用者有 email、email_uni(標準化後的唯一信箱)、groups(群組標記)、f_name 和 l_name。 + +3. **郵件發送流程**: + - `mails.ProcessSend()` 從資料庫查詢使用者清單 + - 對每個使用者,替換 HTML 模板中的個人化標籤({{FNAME}}, {{LNAME}}, {{READER}}) + - 如果啟用 `replaceLink`,會掃描所有 `` 標籤和 `{{WASHI}}` 標籤,將連結替換為追蹤連結 + - 使用 goroutine 並發發送,透過 channel 控制並發數量(預設 7) + - 透過 AWS SES API 發送郵件 + +4. **追蹤機制**: + - **開信追蹤**:在郵件中插入 `{{READER}}` 標籤,轉換為包含 HMAC 簽章的圖片連結,當使用者開信時請求該圖片,server 記錄到 `reader` 表 + - **點擊追蹤**:掃描 HTML 中的 `` 標籤,將原始 URL 存入 `links` 表,替換為追蹤連結(/door/ 路徑),點擊時記錄到 `doors` 表後重導向到原始 URL + - **Washi 標籤**:特殊的連結類型,支援延遲替換個人化參數(如 `{{WASHI}}http://example.com/?name={{FNAME}}{{/WASHI}}`),在點擊時才進行個人化替換 + +5. **安全性設計**:所有追蹤連結都使用 HMAC-SHA256 驗證,基於 campaign seed、campaign ID 和 user ID 生成。Server 端驗證 HMAC 後才記錄追蹤資料,防止偽造追蹤請求。 + +### 主要套件職責 + +- **campaign**:管理 campaign 的建立、種子儲存與 HMAC 生成/驗證 +- **mails**:處理郵件發送、HTML 模板替換、追蹤連結生成、AWS SES 整合 +- **reader**:記錄開信追蹤資料 +- **utils**:提供資料庫連線、HMAC 工具函數、email 標準化函數 +- **cmd**:Cobra CLI 指令實作,包含 campaign、user、send、server 子指令 + +### 資料庫架構 + +- **campaign**:儲存 campaign ID 與加密種子 +- **user**:訂閱者資訊,包含 email、email_uni、groups、名字、alive 狀態 +- **reader**:開信紀錄,記錄 uid、cid、IP、user agent +- **doors**:點擊紀錄,記錄 uid、cid、linkid、IP、user agent +- **links**:連結對應表,儲存 campaign 中使用的原始 URL 及其 hash +- **vote**:投票紀錄表 + +連線字串固定為:`root:mailboxdbs@tcp(MARIADB:3306)/mailbox?parseTime=true` + +## 常用開發指令 + +### 建置與測試 + +```bash +# 建置專案 +go build -v ./... + +# 執行所有測試 +go test -v ./... + +# 執行 race detector 測試 +go test -race ./campaign... +go test -race ./mails... +go test -race ./reader... +go test -race ./utils... + +# 執行測試覆蓋率檢查 +sh ./goclean.sh + +# 執行效能測試 +go test -v -bench=Bench -benchmem -run='Bench' ./mails +go test -v -bench=Bench -benchmem -run='Bench' ./utils +``` + +### Docker 映像建置 + +```bash +# 建置基礎映像(包含程式碼與相依套件) +sh ./build-base.sh + +# 建置最小化映像(只包含編譯後的執行檔) +sh ./build-min.sh +``` + +### 開發環境設定 + +```bash +# 啟動 MariaDB 容器(開發用) +sh ./dev-run-mariadb.sh + +# 啟動 MariaDB 客戶端 +sh ./dev-run-mariadb-client.sh + +# 執行應用程式容器 +sh ./dev-run-docker.sh + +# 初始化資料庫(CI 環境) +mysql -h MARIADB -uroot -pmailboxdbs < ./sql/database.sql +mysql -h MARIADB -uroot -pmailboxdbs mailbox < ./sql/tables.sql +``` + +### CLI 指令使用 + +```bash +# 建立新的 campaign +mailbox campaign create + +# 列出所有 campaign +mailbox campaign list + +# 產生追蹤連結 +mailbox campaign hash --cid [campaign_id] --uid [user_id] + +# 匯入使用者資料(dry run) +mailbox user import ./list.csv -d + +# 匯入使用者資料 +mailbox user import ./list.csv + +# 更新使用者資料 +mailbox user update ./list.csv + +# 顯示群組使用者 +mailbox user show [group_name] + +# 發送電子報(dry run) +mailbox send -p [html_path] -t [text_path] -s "Subject" -g [group] --cid [cid] -d + +# 發送電子報 +mailbox send -p [html_path] -t [text_path] -s "Subject" -g [group] --cid [cid] + +# 發送給特定使用者 +mailbox send -p [html_path] -t [text_path] -s "Subject" --uid="6,12" --cid [cid] + +# 啟動追蹤 server +mailbox server -p :8801 + +# 查看 campaign 開信狀況 +mailbox campaign open [group] [cid1] [cid2] ... + +# 查看開信次數統計 +mailbox campaign opencount [group] [cid] + +# 查看開信歷史 +mailbox campaign openhistory [group] [cid] + +# 查看連結點擊紀錄 +mailbox campaign doors [group] [cid] +``` + +## 環境變數設定 + +執行應用程式需要以下環境變數: + +- `mailbox_ses_key`:AWS SES Access Key +- `mailbox_ses_token`:AWS SES Secret Token +- `mailbox_ses_sender`:發件者信箱(格式:`Sender Name `) +- `mailbox_web_site`:追蹤連結的網域(不含 https 與結尾斜線,如:`open.example.com`) +- `mailbox_ses_replyto`:(選用)回信信箱 + +## HTML 模板標籤 + +郵件 HTML 檔案支援以下替換標籤: + +- `{{FNAME}}`:訂閱者的 first name +- `{{LNAME}}`:訂閱者的 last name +- `{{READER}}`:開信追蹤連結(通常放在 ``) +- `{{WASHI}}...{{/WASHI}}`:延遲替換的個人化連結(如:`{{WASHI}}http://example.com/?name={{FNAME}}{{/WASHI}}`) + +## Nginx 設定需求 + +追蹤 server 需要 Nginx 反向代理,並在設定檔中加入以下 headers: + +```nginx +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Args $query_string; +proxy_set_header X-Uri $uri; +``` + +## 重要實作細節 + +### Email 標準化邏輯 + +`utils.FormatEmail()` 函數會標準化 email 地址: +1. 移除 `+` 符號及其後到 `@` 之前的所有字元 +2. 移除本地部分(@ 之前)的所有 `.` 符號 +3. 轉換為小寫並去除空白 + +這用於 `email_uni` 欄位,確保同一個實體信箱的不同變體(如 `user+tag@gmail.com` 和 `user@gmail.com`)被識別為同一使用者。 + +### 並發控制 + +郵件發送使用 channel 作為信號量(semaphore)控制並發數量,預設為 7。每次發送前會往 channel 送入一個結構體,完成後取出,確保同時最多只有 N 個 goroutine 在發送郵件。 + +### HMAC 快取機制 + +`campaign.GetSeed()` 會快取已查詢過的 campaign seed,避免重複查詢資料庫。快取存放在 `cacheSeed` map 中。 + +### 錯誤重試 + +`mails.Send()` 在發送失敗時會自動重試最多 5 次。 diff --git a/campaign/campaign.go b/campaign/campaign.go index bd27d26..7bbe244 100644 --- a/campaign/campaign.go +++ b/campaign/campaign.go @@ -40,10 +40,12 @@ func GetSeed(campaignID string) string { return seed } rows, err := utils.GetConn().Query(`SELECT seed FROM campaign WHERE id=? `, campaignID) - defer rows.Close() if err != nil { log.Fatal("[campaign][GetSeed] ", err) } + if rows != nil { + defer rows.Close() + } for rows.Next() { rows.Scan(&seed) } @@ -57,9 +59,10 @@ func GetSeed(campaignID string) string { // Create is to create a new campaign id and seed func Create() ([]byte, []byte) { id, seed := utils.GenSeed(), utils.GenSeed() - rows, err := utils.GetConn().Query(fmt.Sprintf(`INSERT INTO campaign(id,seed) VALUES('%x', '%x')`, id, seed)) - defer rows.Close() - + _, err := utils.GetConn().Exec( + `INSERT INTO campaign(id,seed) VALUES(?,?)`, + fmt.Sprintf("%x", id), fmt.Sprintf("%x", seed), + ) if err != nil { log.Fatal("[campaign][Create] ", err) } diff --git a/cmd/send.go b/cmd/send.go index 7dbfe77..fcf84b3 100644 --- a/cmd/send.go +++ b/cmd/send.go @@ -22,10 +22,10 @@ package cmd import ( "database/sql" - "fmt" "io" "log" "os" + "strconv" "strings" "github.com/spf13/cobra" @@ -79,17 +79,25 @@ var sendCmd = &cobra.Command{ var rows *sql.Rows if *sendUID != "" { uids := strings.Split(*sendUID, ",") + placeholders := make([]string, len(uids)) + args := make([]interface{}, len(uids)) for i, v := range uids { - uids[i] = fmt.Sprintf("'%s'", v) + uid := strings.TrimSpace(v) + if _, err := strconv.Atoi(uid); err != nil { + log.Fatal("[cmd][send] invalid uid: ", v) + } + placeholders[i] = "?" + args[i] = uid } - rows, err = sendConn.Query(fmt.Sprintf(`SELECT id,email,f_name,l_name FROM user WHERE alive=1 AND id IN (%s)`, strings.Join(uids, ","))) + query := `SELECT id,email,f_name,l_name FROM user WHERE alive=1 AND id IN (` + strings.Join(placeholders, ",") + `)` + rows, err = sendConn.Query(query, args...) } else { rows, err = sendConn.Query(`SELECT id,email,f_name,l_name FROM user WHERE alive=1 AND groups=?`, *sendGroups) } - defer rows.Close() if err != nil { log.Fatal("[cmd][send][Query] ", err) } + defer rows.Close() mails.ProcessSend(body, body_text, rows, *sendCID, *sendReplaceLink, *sendSubject, *sendDryRun, *sendLimit) }, diff --git a/cmd/server.go b/cmd/server.go index 501b7e8..9330c6e 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -27,6 +27,7 @@ import ( "net/http" "net/url" "regexp" + "sync" "github.com/spf13/cobra" "github.com/toomore/mailbox/campaign" @@ -36,9 +37,10 @@ import ( ) var ( - servercExpr = regexp.MustCompile(`/(read|door|washi|vote)/([0-9a-zA-Z]+)`) - serverhttpPort *string - serverlinksCache = make(map[string]string) + servercExpr = regexp.MustCompile(`/(read|door|washi|vote)/([0-9a-zA-Z]+)`) + serverhttpPort *string + serverlinksCache = make(map[string]string) + serverlinksCacheMu sync.RWMutex ) func serverLog(note string, r *http.Request) { @@ -112,14 +114,20 @@ func door(w http.ResponseWriter, r *http.Request) { url string ) - if url, ok = serverlinksCache[serverlinksCacheKey]; ok { + serverlinksCacheMu.RLock() + url, ok = serverlinksCache[serverlinksCacheKey] + serverlinksCacheMu.RUnlock() + + if ok { log.Println("Using", match[1], "cache", serverlinksCacheKey, url) } else { rows, err := utils.GetConn().Query(`SELECT url FROM links WHERE cid=? AND id=?`, v.Get("c"), v.Get("l")) if err == nil { for rows.Next() { rows.Scan(&url) + serverlinksCacheMu.Lock() serverlinksCache[serverlinksCacheKey] = url + serverlinksCacheMu.Unlock() log.Println("Find", match[1], serverlinksCacheKey, url) } } diff --git a/mails/replace.go b/mails/replace.go index b366a40..8134b72 100644 --- a/mails/replace.go +++ b/mails/replace.go @@ -122,6 +122,5 @@ func filteratags(rg *regexp.Regexp, body *[]byte, cid string) map[string]LinksDa }(v[1]) } wg.Wait() - conn.Close() return result } diff --git a/utils/utils.go b/utils/utils.go index 1955324..2f7431c 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -8,6 +8,7 @@ import ( "log" "regexp" "strings" + "sync" ) const ( @@ -15,6 +16,11 @@ const ( SQLPATH = "root:mailboxdbs@tcp(MARIADB:3306)/mailbox?parseTime=true" ) +var ( + dbOnce sync.Once + dbInstance *sql.DB +) + // GenSeed is to gen seed func GenSeed() []byte { var buf = make([]byte, 4) @@ -31,17 +37,19 @@ func GenHmac(key, message []byte) []byte { // GetConn DB conn func GetConn() *sql.DB { - var err error - var conn *sql.DB - if conn, err = sql.Open("mysql", SQLPATH); err != nil { - log.Fatal("[GetConn] ", err) - } - conn.SetMaxOpenConns(1024) - if err := conn.Ping(); err != nil { - log.Fatal("[GetConn][Ping] ", err) - } - - return conn + dbOnce.Do(func() { + var err error + dbInstance, err = sql.Open("mysql", SQLPATH) + if err != nil { + log.Fatal("[GetConn] ", err) + } + dbInstance.SetMaxOpenConns(50) + dbInstance.SetMaxIdleConns(10) + if err := dbInstance.Ping(); err != nil { + log.Fatal("[GetConn][Ping] ", err) + } + }) + return dbInstance } // FormatEmail is to make mail address unique @@ -50,6 +58,9 @@ func FormatEmail(email string) string { email = re.ReplaceAllString(strings.TrimSpace(strings.ToLower(email)), "") parts := strings.Split(email, "@") + if len(parts) != 2 { + return strings.TrimSpace(strings.ToLower(email)) + } localPart, domain := parts[0], parts[1] localPart = strings.ReplaceAll(localPart, ".", "") diff --git a/utils/utils_test.go b/utils/utils_test.go index 254d86c..6913cf8 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -47,6 +47,14 @@ func TestFormatEmail(t *testing.T) { } } +func TestFormatEmailInvalid(t *testing.T) { + // Email without @ should not panic, returns trimmed/lowercased input + got := FormatEmail("noatsign") + if got != "noatsign" { + t.Fatalf("expected \"noatsign\", got %q", got) + } +} + func BenchmarkFormatEmail(b *testing.B) { for i := 0; i < b.N; i++ { FormatEmail("toomore.chiang+123@gmail.com") From 958767a19db249c364a14c570bc66f3e9ee64622 Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Sun, 8 Feb 2026 02:34:05 +0800 Subject: [PATCH 11/19] fix(server): add rows Close, fix connection leaks, double-check cache lock - Add rows.Close() after links query in door() to prevent connection leak - Add defer userrows.Close() in washi() for user query - Apply double-check locking for serverlinksCache to reduce DB thundering herd when multiple goroutines request same missing cache key Co-authored-by: Cursor --- cmd/server.go | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/cmd/server.go b/cmd/server.go index 9330c6e..a3d6d7a 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -70,6 +70,7 @@ func washi(v url.Values, url string) []byte { if len(washigroup) > 1 { userrows, err := utils.GetConn().Query(`SELECT f_name, l_name FROM user WHERE id=?`, v.Get("u")) if err == nil { + defer userrows.Close() washiURL := []byte(washigroup[1]) for userrows.Next() { var ( @@ -121,15 +122,22 @@ func door(w http.ResponseWriter, r *http.Request) { if ok { log.Println("Using", match[1], "cache", serverlinksCacheKey, url) } else { - rows, err := utils.GetConn().Query(`SELECT url FROM links WHERE cid=? AND id=?`, v.Get("c"), v.Get("l")) - if err == nil { - for rows.Next() { - rows.Scan(&url) - serverlinksCacheMu.Lock() - serverlinksCache[serverlinksCacheKey] = url - serverlinksCacheMu.Unlock() - log.Println("Find", match[1], serverlinksCacheKey, url) + serverlinksCacheMu.Lock() + url, ok = serverlinksCache[serverlinksCacheKey] + if ok { + serverlinksCacheMu.Unlock() + log.Println("Using", match[1], "cache", serverlinksCacheKey, url) + } else { + rows, err := utils.GetConn().Query(`SELECT url FROM links WHERE cid=? AND id=?`, v.Get("c"), v.Get("l")) + if err == nil { + for rows.Next() { + rows.Scan(&url) + serverlinksCache[serverlinksCacheKey] = url + log.Println("Find", match[1], serverlinksCacheKey, url) + } + rows.Close() } + serverlinksCacheMu.Unlock() } } From 82e4d7cc20cda7e942e9b0065593514d7871bce9 Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Sun, 8 Feb 2026 02:38:05 +0800 Subject: [PATCH 12/19] fix(phase3): rows Close leaks, send defensive check, DSN from env - Add defer rows.Close() in updateUser to fix connection leak per iteration - Add rows.Close() in filteratags fallback SELECT to fix goroutine leak - Add rows != nil check before defer in send for defensive safety - Add getDSN() to support MAILBOX_DB_DSN env for configurable DSN Co-authored-by: Cursor --- cmd/send.go | 4 +++- cmd/user.go | 1 + mails/replace.go | 7 +++++-- utils/utils.go | 11 ++++++++++- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/cmd/send.go b/cmd/send.go index fcf84b3..b407008 100644 --- a/cmd/send.go +++ b/cmd/send.go @@ -97,7 +97,9 @@ var sendCmd = &cobra.Command{ if err != nil { log.Fatal("[cmd][send][Query] ", err) } - defer rows.Close() + if rows != nil { + defer rows.Close() + } mails.ProcessSend(body, body_text, rows, *sendCID, *sendReplaceLink, *sendSubject, *sendDryRun, *sendLimit) }, diff --git a/cmd/user.go b/cmd/user.go index 8b0966d..9b3c933 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -114,6 +114,7 @@ func updateUser(data []user) { if err != nil { log.Fatal("[cmd][updateUser][Prepare]", err) } + defer rows.Close() var c int for rows.Next() { rows.Scan(&c) diff --git a/mails/replace.go b/mails/replace.go index 8134b72..c1a21db 100644 --- a/mails/replace.go +++ b/mails/replace.go @@ -107,8 +107,11 @@ func filteratags(rg *regexp.Regexp, body *[]byte, cid string) map[string]LinksDa _, err := conn.Query(`INSERT INTO links(id,cid,url,urlhash) VALUES(?,?,?,?)`, linkID, cid, url, md5hstr) if err != nil { rows, _ := conn.Query(`SELECT id FROM links WHERE cid=? AND urlhash=?`, cid, md5hstr) - for rows.Next() { - rows.Scan(&linkID) + if rows != nil { + defer rows.Close() + for rows.Next() { + rows.Scan(&linkID) + } } } lock.Lock() diff --git a/utils/utils.go b/utils/utils.go index 2f7431c..943f64e 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "database/sql" "log" + "os" "regexp" "strings" "sync" @@ -21,6 +22,14 @@ var ( dbInstance *sql.DB ) +// getDSN returns DSN from env MAILBOX_DB_DSN or default SQLPATH +func getDSN() string { + if s := os.Getenv("MAILBOX_DB_DSN"); s != "" { + return s + } + return SQLPATH +} + // GenSeed is to gen seed func GenSeed() []byte { var buf = make([]byte, 4) @@ -39,7 +48,7 @@ func GenHmac(key, message []byte) []byte { func GetConn() *sql.DB { dbOnce.Do(func() { var err error - dbInstance, err = sql.Open("mysql", SQLPATH) + dbInstance, err = sql.Open("mysql", getDSN()) if err != nil { log.Fatal("[GetConn] ", err) } From c2e9825017b64c7e6122cc80f9ab8f36cf05141b Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Sun, 8 Feb 2026 02:43:17 +0800 Subject: [PATCH 13/19] docs: rewrite README with bilingual Chinese/English format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add bilingual sections (中文/English) for overview, quick start, install - Include full env vars table with MAILBOX_DB_DSN - Document complete CLI commands (campaign, user, send, server) - Add template tags and Nginx config tables - Remove deprecated Travis/Coveralls badges Co-authored-by: Cursor --- README.md | 175 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 116 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index f932c38..9a6e3d6 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,138 @@ -Mailbox [![GitHub release](https://img.shields.io/github/release/toomore/mailbox.svg)](https://github.com/toomore/mailbox/releases) [![Build Status](https://travis-ci.org/toomore/mailbox.svg?branch=master)](https://travis-ci.org/toomore/mailbox) [![Coverage Status](https://coveralls.io/repos/github/toomore/mailbox/badge.svg)](https://coveralls.io/github/toomore/mailbox) [![license](https://img.shields.io/github/license/toomore/mailbox.svg)](https://github.com/toomore/mailbox/blob/master/LICENSE) -===================== -簡易電子報發送系統,使用 Golang 實作。建立發送 `campaign` 資訊、匯入訂閱者資訊(群組標記)、簡易發送系統、開信追蹤與連結點擊追蹤。 +# Mailbox -以 docker container 運行。 +[![GitHub release](https://img.shields.io/github/release/toomore/mailbox.svg)](https://github.com/toomore/mailbox/releases) +[![license](https://img.shields.io/github/license/toomore/mailbox.svg)](https://github.com/toomore/mailbox/blob/master/LICENSE) -Cmd ----- -1. `mailbox campaign`:建立 `campaign`,包含產生該 `campaign` 的亂數種子。 -2. `mailbox user`:匯入訂閱者的資訊。 -3. `mailbox send`:發送電子報,以 **HTML** 格式發送。 -4. `mailbox server`:接收開信訊息。 +--- -相關的操作請參考 `-h` 的說明,或 [cmd/docs](cmd/docs/mailbox.md) +## 專案概述 / Overview -Build Docker Image -------------------- -### `toomore/mailbox:base` -將基本的程式碼放入,相關的套件也一併下載。 +**中文:** Mailbox 是一個簡易電子報發送系統,使用 Golang 實作。主要功能包括:建立發送 campaign 資訊、匯入訂閱者資訊(群組標記)、發送 HTML 格式電子報、開信追蹤與連結點擊追蹤。系統設計為在 Docker 容器中運行。 - sh ./build-base.sh +**English:** Mailbox is a simple newsletter sending system built with Go. It supports campaign management, subscriber import (with group tags), HTML email sending, open tracking, and link click tracking. Designed to run in Docker containers. -### `toomore/mailbox:cmd` -只將編譯過的 `cmd` 程式放入。 +--- - sh ./build-min.sh; +## 快速開始 / Quick Start -Required ---------- -1. AWS SES `KEY`, `Token`. -2. Update `./Makefile run_cmd`, `./dev-run-cmd.sh` files. - 1. `mailbox_ses_key`:AWS SES KEY - 2. `mailbox_ses_token`:AWS SES Token - 3. `mailbox_ses_sender`:發送者的 `email`。如:`Toomore Chiang `. - 4. `mailbox_web_site`:接收開信網址,不包含 `https` 與結尾。如:`open.example.com`. - 5. `mailbox_ses_replyto`: (選擇) 回信時的信箱。 -3. Nginx +**中文:** 執行前需準備 AWS SES、MariaDB、Nginx。基本流程:建立 campaign → 匯入訂閱者 → 設定環境變數 → 發送電子報 → 啟動追蹤 server。 -Nginx config -------------- -需加入以下資訊到網域設定檔。 +**English:** Requirements: AWS SES, MariaDB, Nginx. Basic flow: create campaign → import subscribers → set env vars → send newsletter → run tracking server. - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Args $query_string; - proxy_set_header X-Uri $uri; +--- -Run ----- -Into container +## 安裝與建置 / Installation & Build - make run_cmd +### Docker 映像 / Docker Images -Support bash-completion +**中文:** - mailbox [tab][tab] - campaign send server user +- `toomore/mailbox:base`:包含程式碼與相依套件 +- `toomore/mailbox:cmd`:僅包含編譯後的執行檔 -... and pretty help (using [spf13/cobra](https://github.com/spf13/cobra)) +```bash +sh ./build-base.sh # 基礎映像 +sh ./build-min.sh # 最小化映像 +``` - mailbox -h +**English:** -Import User data from csv --------------------------- -匯入訂閱者資訊的 csv 檔案,檔案內需包含 `email`, `groups`, `f_name`, `l_name` 欄位。 +- `toomore/mailbox:base`: Includes source and dependencies +- `toomore/mailbox:cmd`: Contains only the compiled binary - mailbox user import ./list.csv +### 開發環境 / Development -可以使用 `-d` 來預覽資料讀取狀況 +```bash +sh ./dev-run-mariadb.sh # 啟動 MariaDB 容器 +sh ./dev-run-docker.sh # 執行應用程式容器 +sh ./dev-run-mariadb-client.sh # MariaDB 客戶端 +``` - mailbox user import ./list.csv -d - ... +--- -Template Replace ------------------ -利用 `mailbox send -p [HTML path]` 時,可以在指定的 HTML 檔案中放入可被替換參數。 +## 環境變數 / Environment Variables -- `{{FNAME}}`:訂閱者的 `first name` -- `{{LNAME}}`:訂閱者的 `last name` -- `{{READER}}`:放置開信追蹤連結。例:`` -- `{{WASHI}}...{{/WASHI}}`:放置點擊後才替換的參數。例:`{{WASHI}}http://example.com/?lname={{LNAME}}{{/WASHI}}` +| 變數 Variable | 說明 Description | +|---------------|------------------| +| `mailbox_ses_key` | AWS SES Access Key | +| `mailbox_ses_token` | AWS SES Secret Token | +| `mailbox_ses_sender` | 發件者信箱,格式:`Name ` | +| `mailbox_web_site` | 追蹤連結網域(不含 https 與結尾斜線),如:`open.example.com` | +| `mailbox_ses_replyto` | (選用)回信信箱 | +| `MAILBOX_DB_DSN` | (選用)資料庫連線字串,覆寫預設 DSN | + +--- + +## CLI 指令 / Commands + +完整說明請執行 `mailbox -h` 或參考 [cmd/docs](cmd/docs/mailbox.md)。 + +### Campaign + +```bash +mailbox campaign create # 建立 campaign +mailbox campaign list # 列出所有 campaign +mailbox campaign hash --cid [cid] --uid [uid] # 產生追蹤連結 +mailbox campaign open [group] [cid1] [cid2] # 開信狀況 +mailbox campaign opencount [group] [cid] # 開信次數統計 +mailbox campaign openhistory [group] [cid] # 開信歷史 +mailbox campaign doors [group] [cid] # 連結點擊紀錄 +``` + +### User + +```bash +mailbox user import ./list.csv # 匯入訂閱者(CSV 需含 email, groups, f_name, l_name) +mailbox user import ./list.csv -d # 預覽模式(dry run) +mailbox user update ./list.csv # 更新訂閱者(需含 alive 欄位) +mailbox user show [group] # 顯示群組使用者 +``` + +### Send + +```bash +# 依群組發送 +mailbox send -p [html] -t [text] -s "Subject" -g [group] --cid [cid] + +# 發送給特定使用者 +mailbox send -p [html] -t [text] -s "Subject" --uid="6,12" --cid [cid] + +# 預覽模式 +mailbox send -p [html] -t [text] -s "Subject" -g [group] --cid [cid] -d +``` + +### Server + +```bash +mailbox server -p :8801 # 啟動追蹤 server,接收開信/點擊紀錄 +``` + +--- + +## 模板標籤 / Template Tags + +| 標籤 Tag | 說明 Description | +|----------|------------------| +| `{{FNAME}}` | 訂閱者 first name | +| `{{LNAME}}` | 訂閱者 last name | +| `{{READER}}` | 開信追蹤連結,例:`` | +| `{{WASHI}}...{{/WASHI}}` | 點擊時才替換的個人化連結,例:`{{WASHI}}http://example.com/?lname={{LNAME}}{{/WASHI}}` | + +--- + +## Nginx 設定 / Nginx Config + +追蹤 server 需經 Nginx 反向代理,並加入以下 headers: + +```nginx +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Args $query_string; +proxy_set_header X-Uri $uri; +``` + +--- + +## 授權 / License + +[MIT](LICENSE) From d855ab9701b24c4099aba7720878fd284682d344 Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Sun, 8 Feb 2026 02:48:05 +0800 Subject: [PATCH 14/19] fix(cmd): gendoc output path, regenerate docs, fix README - Change gendoc output from ./ to ./cmd/docs for predictable output - Regenerate all cobra docs (mailbox doc) with current command tree - Fix cmd/README.md: mailbox sender -> mailbox send Co-authored-by: Cursor --- cmd/README.md | 4 +- cmd/docs/mailbox.md | 5 ++- cmd/docs/mailbox_campaign.md | 4 +- cmd/docs/mailbox_campaign_create.md | 4 +- cmd/docs/mailbox_campaign_doors.md | 4 +- cmd/docs/mailbox_campaign_hash.md | 4 +- cmd/docs/mailbox_campaign_list.md | 4 +- cmd/docs/mailbox_campaign_open.md | 4 +- cmd/docs/mailbox_campaign_opencount.md | 4 +- cmd/docs/mailbox_campaign_openhistory.md | 4 +- cmd/docs/mailbox_completion.md | 31 ++++++++++++++ cmd/docs/mailbox_completion_bash.md | 50 ++++++++++++++++++++++ cmd/docs/mailbox_completion_fish.md | 41 ++++++++++++++++++ cmd/docs/mailbox_completion_powershell.md | 38 +++++++++++++++++ cmd/docs/mailbox_completion_zsh.md | 52 +++++++++++++++++++++++ cmd/docs/mailbox_send.md | 5 ++- cmd/docs/mailbox_server.md | 4 +- cmd/docs/mailbox_user.md | 2 +- cmd/docs/mailbox_user_import.md | 2 +- cmd/docs/mailbox_user_show.md | 2 +- cmd/docs/mailbox_user_update.md | 2 +- cmd/gendoc.go | 2 +- 22 files changed, 243 insertions(+), 29 deletions(-) create mode 100644 cmd/docs/mailbox_completion.md create mode 100644 cmd/docs/mailbox_completion_bash.md create mode 100644 cmd/docs/mailbox_completion_fish.md create mode 100644 cmd/docs/mailbox_completion_powershell.md create mode 100644 cmd/docs/mailbox_completion_zsh.md diff --git a/cmd/README.md b/cmd/README.md index 445035e..0c83955 100644 --- a/cmd/README.md +++ b/cmd/README.md @@ -2,7 +2,7 @@ Mailbox CMD [![GoDoc](https://godoc.org/github.com/toomore/mailbox/cmd?status.sv ============ 四個主要的命令來運作 **Mailbox**,`mailbox campaign`, `mailbox user`, -`mailbox sender`, `mailbox server` +`mailbox send`, `mailbox server` 也提供將程式編譯後再放入 `alpine`。 @@ -15,7 +15,7 @@ CMD ### `mailbox user` 匯入訂閱者的資訊。 -### `mailbox sender` +### `mailbox send` 發送電子報,以 **HTML** 格式發送。 ### `mailbox server` diff --git a/cmd/docs/mailbox.md b/cmd/docs/mailbox.md index 98c4fe8..b47f82a 100644 --- a/cmd/docs/mailbox.md +++ b/cmd/docs/mailbox.md @@ -4,7 +4,6 @@ ### Synopsis - 簡易電子報發送系統,使用 Golang 實作。建立發送 campaign 資訊、匯入訂閱者資訊(群組標記)、簡易發送系統、開信追蹤與連結點擊追蹤。 以 docker container 運行。 @@ -21,9 +20,11 @@ mailbox [flags] ``` ### SEE ALSO + * [mailbox campaign](mailbox_campaign.md) - Campaign operator +* [mailbox completion](mailbox_completion.md) - Generate the autocompletion script for the specified shell * [mailbox send](mailbox_send.md) - Send paper * [mailbox server](mailbox_server.md) - Run tiny server for open, click trace * [mailbox user](mailbox_user.md) - User info -###### Auto generated by spf13/cobra on 29-Jun-2017 +###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_campaign.md b/cmd/docs/mailbox_campaign.md index d34a44a..90ff273 100644 --- a/cmd/docs/mailbox_campaign.md +++ b/cmd/docs/mailbox_campaign.md @@ -4,7 +4,6 @@ Campaign operator ### Synopsis - 相關 campaign 的操作 ``` @@ -24,6 +23,7 @@ mailbox campaign [flags] ``` ### SEE ALSO + * [mailbox](mailbox.md) - 簡易電子報發送系統 * [mailbox campaign create](mailbox_campaign_create.md) - Create a campaign * [mailbox campaign doors](mailbox_campaign_doors.md) - Campaign click url by group by cid @@ -33,4 +33,4 @@ mailbox campaign [flags] * [mailbox campaign opencount](mailbox_campaign_opencount.md) - Count campaign open and list first/latest open by group by cid * [mailbox campaign openhistory](mailbox_campaign_openhistory.md) - Campaign open history by group by cid -###### Auto generated by spf13/cobra on 24-Jul-2017 +###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_campaign_create.md b/cmd/docs/mailbox_campaign_create.md index bba8a6a..dcac774 100644 --- a/cmd/docs/mailbox_campaign_create.md +++ b/cmd/docs/mailbox_campaign_create.md @@ -4,7 +4,6 @@ Create a campaign ### Synopsis - 新增一個 campaign 序號與加密種子,加密種子會在每次寄送電子報時使用。 ``` @@ -24,6 +23,7 @@ mailbox campaign create [flags] ``` ### SEE ALSO + * [mailbox campaign](mailbox_campaign.md) - Campaign operator -###### Auto generated by spf13/cobra on 29-Jun-2017 +###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_campaign_doors.md b/cmd/docs/mailbox_campaign_doors.md index 6c7b0b5..d7688f8 100644 --- a/cmd/docs/mailbox_campaign_doors.md +++ b/cmd/docs/mailbox_campaign_doors.md @@ -4,7 +4,6 @@ Campaign click url by group by cid ### Synopsis - 依群組名單列出所有的連結點擊紀錄,支援多組 cid 依序列出。 ``` @@ -24,6 +23,7 @@ mailbox campaign doors [group] [cid ...] [flags] ``` ### SEE ALSO + * [mailbox campaign](mailbox_campaign.md) - Campaign operator -###### Auto generated by spf13/cobra on 24-Jul-2017 +###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_campaign_hash.md b/cmd/docs/mailbox_campaign_hash.md index 610cf5f..affc9d0 100644 --- a/cmd/docs/mailbox_campaign_hash.md +++ b/cmd/docs/mailbox_campaign_hash.md @@ -4,7 +4,6 @@ Hash cid, uid ### Synopsis - 產生一組開信追蹤連結,需要 cid, uid ``` @@ -26,6 +25,7 @@ mailbox campaign hash [flags] ``` ### SEE ALSO + * [mailbox campaign](mailbox_campaign.md) - Campaign operator -###### Auto generated by spf13/cobra on 29-Jun-2017 +###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_campaign_list.md b/cmd/docs/mailbox_campaign_list.md index bfb50b2..5666a2e 100644 --- a/cmd/docs/mailbox_campaign_list.md +++ b/cmd/docs/mailbox_campaign_list.md @@ -4,7 +4,6 @@ List campaign ### Synopsis - 列出所有 campaign 資訊 ``` @@ -24,6 +23,7 @@ mailbox campaign list [flags] ``` ### SEE ALSO + * [mailbox campaign](mailbox_campaign.md) - Campaign operator -###### Auto generated by spf13/cobra on 29-Jun-2017 +###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_campaign_open.md b/cmd/docs/mailbox_campaign_open.md index 00b5b35..a4e59e0 100644 --- a/cmd/docs/mailbox_campaign_open.md +++ b/cmd/docs/mailbox_campaign_open.md @@ -4,7 +4,6 @@ Campaign open by group by cid ### Synopsis - 依群組名單列出 campaign 的開信狀況,支援多組 cid 依序列出 ``` @@ -24,6 +23,7 @@ mailbox campaign open [group] [cid ...] [flags] ``` ### SEE ALSO + * [mailbox campaign](mailbox_campaign.md) - Campaign operator -###### Auto generated by spf13/cobra on 29-Jun-2017 +###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_campaign_opencount.md b/cmd/docs/mailbox_campaign_opencount.md index 91f3766..039b85f 100644 --- a/cmd/docs/mailbox_campaign_opencount.md +++ b/cmd/docs/mailbox_campaign_opencount.md @@ -4,7 +4,6 @@ Count campaign open and list first/latest open by group by cid ### Synopsis - 依群組名單統計開信次數、首次、最近的開信時間。支援多組 cid 依序列出。 ``` @@ -24,6 +23,7 @@ mailbox campaign opencount [group] [cid ...] [flags] ``` ### SEE ALSO + * [mailbox campaign](mailbox_campaign.md) - Campaign operator -###### Auto generated by spf13/cobra on 29-Jun-2017 +###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_campaign_openhistory.md b/cmd/docs/mailbox_campaign_openhistory.md index 2acb566..e85a638 100644 --- a/cmd/docs/mailbox_campaign_openhistory.md +++ b/cmd/docs/mailbox_campaign_openhistory.md @@ -4,7 +4,6 @@ Campaign open history by group by cid ### Synopsis - 依群組名單列出所有的開信紀錄,支援多組 cid 依序列出。 ``` @@ -24,6 +23,7 @@ mailbox campaign openhistory [group] [cid ...] [flags] ``` ### SEE ALSO + * [mailbox campaign](mailbox_campaign.md) - Campaign operator -###### Auto generated by spf13/cobra on 29-Jun-2017 +###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_completion.md b/cmd/docs/mailbox_completion.md new file mode 100644 index 0000000..10bcd8d --- /dev/null +++ b/cmd/docs/mailbox_completion.md @@ -0,0 +1,31 @@ +## mailbox completion + +Generate the autocompletion script for the specified shell + +### Synopsis + +Generate the autocompletion script for mailbox for the specified shell. +See each sub-command's help for details on how to use the generated script. + + +### Options + +``` + -h, --help help for completion +``` + +### Options inherited from parent commands + +``` + --config string config file (default is $HOME/.mailbox.yaml) +``` + +### SEE ALSO + +* [mailbox](mailbox.md) - 簡易電子報發送系統 +* [mailbox completion bash](mailbox_completion_bash.md) - Generate the autocompletion script for bash +* [mailbox completion fish](mailbox_completion_fish.md) - Generate the autocompletion script for fish +* [mailbox completion powershell](mailbox_completion_powershell.md) - Generate the autocompletion script for powershell +* [mailbox completion zsh](mailbox_completion_zsh.md) - Generate the autocompletion script for zsh + +###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_completion_bash.md b/cmd/docs/mailbox_completion_bash.md new file mode 100644 index 0000000..0c8b728 --- /dev/null +++ b/cmd/docs/mailbox_completion_bash.md @@ -0,0 +1,50 @@ +## mailbox completion bash + +Generate the autocompletion script for bash + +### Synopsis + +Generate the autocompletion script for the bash shell. + +This script depends on the 'bash-completion' package. +If it is not installed already, you can install it via your OS's package manager. + +To load completions in your current shell session: + + source <(mailbox completion bash) + +To load completions for every new session, execute once: + +#### Linux: + + mailbox completion bash > /etc/bash_completion.d/mailbox + +#### macOS: + + mailbox completion bash > $(brew --prefix)/etc/bash_completion.d/mailbox + +You will need to start a new shell for this setup to take effect. + + +``` +mailbox completion bash +``` + +### Options + +``` + -h, --help help for bash + --no-descriptions disable completion descriptions +``` + +### Options inherited from parent commands + +``` + --config string config file (default is $HOME/.mailbox.yaml) +``` + +### SEE ALSO + +* [mailbox completion](mailbox_completion.md) - Generate the autocompletion script for the specified shell + +###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_completion_fish.md b/cmd/docs/mailbox_completion_fish.md new file mode 100644 index 0000000..0cc3de6 --- /dev/null +++ b/cmd/docs/mailbox_completion_fish.md @@ -0,0 +1,41 @@ +## mailbox completion fish + +Generate the autocompletion script for fish + +### Synopsis + +Generate the autocompletion script for the fish shell. + +To load completions in your current shell session: + + mailbox completion fish | source + +To load completions for every new session, execute once: + + mailbox completion fish > ~/.config/fish/completions/mailbox.fish + +You will need to start a new shell for this setup to take effect. + + +``` +mailbox completion fish [flags] +``` + +### Options + +``` + -h, --help help for fish + --no-descriptions disable completion descriptions +``` + +### Options inherited from parent commands + +``` + --config string config file (default is $HOME/.mailbox.yaml) +``` + +### SEE ALSO + +* [mailbox completion](mailbox_completion.md) - Generate the autocompletion script for the specified shell + +###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_completion_powershell.md b/cmd/docs/mailbox_completion_powershell.md new file mode 100644 index 0000000..1116fe6 --- /dev/null +++ b/cmd/docs/mailbox_completion_powershell.md @@ -0,0 +1,38 @@ +## mailbox completion powershell + +Generate the autocompletion script for powershell + +### Synopsis + +Generate the autocompletion script for powershell. + +To load completions in your current shell session: + + mailbox completion powershell | Out-String | Invoke-Expression + +To load completions for every new session, add the output of the above command +to your powershell profile. + + +``` +mailbox completion powershell [flags] +``` + +### Options + +``` + -h, --help help for powershell + --no-descriptions disable completion descriptions +``` + +### Options inherited from parent commands + +``` + --config string config file (default is $HOME/.mailbox.yaml) +``` + +### SEE ALSO + +* [mailbox completion](mailbox_completion.md) - Generate the autocompletion script for the specified shell + +###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_completion_zsh.md b/cmd/docs/mailbox_completion_zsh.md new file mode 100644 index 0000000..fedd87b --- /dev/null +++ b/cmd/docs/mailbox_completion_zsh.md @@ -0,0 +1,52 @@ +## mailbox completion zsh + +Generate the autocompletion script for zsh + +### Synopsis + +Generate the autocompletion script for the zsh shell. + +If shell completion is not already enabled in your environment you will need +to enable it. You can execute the following once: + + echo "autoload -U compinit; compinit" >> ~/.zshrc + +To load completions in your current shell session: + + source <(mailbox completion zsh) + +To load completions for every new session, execute once: + +#### Linux: + + mailbox completion zsh > "${fpath[1]}/_mailbox" + +#### macOS: + + mailbox completion zsh > $(brew --prefix)/share/zsh/site-functions/_mailbox + +You will need to start a new shell for this setup to take effect. + + +``` +mailbox completion zsh [flags] +``` + +### Options + +``` + -h, --help help for zsh + --no-descriptions disable completion descriptions +``` + +### Options inherited from parent commands + +``` + --config string config file (default is $HOME/.mailbox.yaml) +``` + +### SEE ALSO + +* [mailbox completion](mailbox_completion.md) - Generate the autocompletion script for the specified shell + +###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_send.md b/cmd/docs/mailbox_send.md index 72d4ed1..bbc7148 100644 --- a/cmd/docs/mailbox_send.md +++ b/cmd/docs/mailbox_send.md @@ -4,7 +4,6 @@ Send paper ### Synopsis - 寄送電子報,處理開信連結與替換點擊連結。 ``` @@ -29,6 +28,7 @@ mailbox send [flags] -p, --path string HTML file path --rl Replace A tag links (default true) -s, --subject string Mail subject + -t, --text string Plain file path --uid string User ID, support more by splited with ',' ``` @@ -39,6 +39,7 @@ mailbox send [flags] ``` ### SEE ALSO + * [mailbox](mailbox.md) - 簡易電子報發送系統 -###### Auto generated by spf13/cobra on 12-Jul-2017 +###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_server.md b/cmd/docs/mailbox_server.md index 26d51c1..deed06b 100644 --- a/cmd/docs/mailbox_server.md +++ b/cmd/docs/mailbox_server.md @@ -4,7 +4,6 @@ Run tiny server for open, click trace ### Synopsis - 啟動一個 web server,來接收開信、點擊連結紀錄。 ``` @@ -25,6 +24,7 @@ mailbox server [flags] ``` ### SEE ALSO + * [mailbox](mailbox.md) - 簡易電子報發送系統 -###### Auto generated by spf13/cobra on 29-Jun-2017 +###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_user.md b/cmd/docs/mailbox_user.md index c84284c..cc03af6 100644 --- a/cmd/docs/mailbox_user.md +++ b/cmd/docs/mailbox_user.md @@ -30,4 +30,4 @@ mailbox user [flags] * [mailbox user show](mailbox_user_show.md) - Show users * [mailbox user update](mailbox_user_update.md) - Update user from csv -###### Auto generated by spf13/cobra on 17-Dec-2017 +###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_user_import.md b/cmd/docs/mailbox_user_import.md index 90b95e6..a8f0f5d 100644 --- a/cmd/docs/mailbox_user_import.md +++ b/cmd/docs/mailbox_user_import.md @@ -28,4 +28,4 @@ mailbox user import [csv path ...] [flags] * [mailbox user](mailbox_user.md) - User info -###### Auto generated by spf13/cobra on 17-Dec-2017 +###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_user_show.md b/cmd/docs/mailbox_user_show.md index a188a6e..58d2a1c 100644 --- a/cmd/docs/mailbox_user_show.md +++ b/cmd/docs/mailbox_user_show.md @@ -27,4 +27,4 @@ mailbox user show [groups ...] [flags] * [mailbox user](mailbox_user.md) - User info -###### Auto generated by spf13/cobra on 17-Dec-2017 +###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_user_update.md b/cmd/docs/mailbox_user_update.md index 50eaf70..f2f21c5 100644 --- a/cmd/docs/mailbox_user_update.md +++ b/cmd/docs/mailbox_user_update.md @@ -28,4 +28,4 @@ mailbox user update [csv path ...] [flags] * [mailbox user](mailbox_user.md) - User info -###### Auto generated by spf13/cobra on 17-Dec-2017 +###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/gendoc.go b/cmd/gendoc.go index 3b645bf..c400664 100644 --- a/cmd/gendoc.go +++ b/cmd/gendoc.go @@ -36,7 +36,7 @@ var gendocCmd = &cobra.Command{ RootCmd.GenBashCompletionFile("./mailbox") cmd.Println("Gen Bash Completion File ...") } else { - doc.GenMarkdownTree(RootCmd, "./") + doc.GenMarkdownTree(RootCmd, "./cmd/docs") cmd.Println("Gen Markdown Tree ...") } }, From c66279dcecff6e228ac7dde0efc6e517041d9fad Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Sun, 8 Feb 2026 03:02:10 +0800 Subject: [PATCH 15/19] chore: implement project improvement plan (phase 2) - security: add match nil check in server.go door() to prevent panic - ci: upgrade actions/checkout to v4, add go vet, use coverage in workflow - goclean: simplify to vet + test + cover report (no merge logic) - reader: add error handling in Save(), use Exec instead of Query - reader: add reader_test.go smoke test - build: ignore cmd/docs/mailbox_completion*.md, remove from tracking Co-authored-by: Cursor --- .github/workflows/go.yml | 12 +++--- .gitignore | 1 + cmd/docs/mailbox_completion.md | 31 -------------- cmd/docs/mailbox_completion_bash.md | 50 ---------------------- cmd/docs/mailbox_completion_fish.md | 41 ------------------ cmd/docs/mailbox_completion_powershell.md | 38 ----------------- cmd/docs/mailbox_completion_zsh.md | 52 ----------------------- cmd/server.go | 4 ++ goclean.sh | 52 ++--------------------- reader/reader.go | 11 ++++- reader/reader_test.go | 13 ++++++ 11 files changed, 37 insertions(+), 268 deletions(-) delete mode 100644 cmd/docs/mailbox_completion.md delete mode 100644 cmd/docs/mailbox_completion_bash.md delete mode 100644 cmd/docs/mailbox_completion_fish.md delete mode 100644 cmd/docs/mailbox_completion_powershell.md delete mode 100644 cmd/docs/mailbox_completion_zsh.md create mode 100644 reader/reader_test.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index bc6c67b..0214a8b 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -17,7 +17,7 @@ jobs: options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=4s --health-retries=5 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Add hosts to /etc/hosts run: | @@ -44,17 +44,17 @@ jobs: - name: Build run: go build -v ./... + - name: Vet + run: go vet ./... + - name: Test run: go test -v ./... - name: Run more test run: | go get -v ./... - go test -race ./campaign... - go test -race ./mails... - go test -race ./reader... - go test -race ./utils... - sh ./goclean.sh + go test -race -coverprofile=coverage.out -covermode=atomic ./... + go tool cover -func=coverage.out go test -v -bench=Bench -benchmem -run='Bench' ./mails go test -v -bench=Bench -benchmem -run='Bench' ./utils - name: Build image diff --git a/.gitignore b/.gitignore index 2a39a65..3eb6cdd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ Makefile +cmd/docs/mailbox_completion*.md csv/ dev-run-docker-dev.sh dev-run-docker-test.sh diff --git a/cmd/docs/mailbox_completion.md b/cmd/docs/mailbox_completion.md deleted file mode 100644 index 10bcd8d..0000000 --- a/cmd/docs/mailbox_completion.md +++ /dev/null @@ -1,31 +0,0 @@ -## mailbox completion - -Generate the autocompletion script for the specified shell - -### Synopsis - -Generate the autocompletion script for mailbox for the specified shell. -See each sub-command's help for details on how to use the generated script. - - -### Options - -``` - -h, --help help for completion -``` - -### Options inherited from parent commands - -``` - --config string config file (default is $HOME/.mailbox.yaml) -``` - -### SEE ALSO - -* [mailbox](mailbox.md) - 簡易電子報發送系統 -* [mailbox completion bash](mailbox_completion_bash.md) - Generate the autocompletion script for bash -* [mailbox completion fish](mailbox_completion_fish.md) - Generate the autocompletion script for fish -* [mailbox completion powershell](mailbox_completion_powershell.md) - Generate the autocompletion script for powershell -* [mailbox completion zsh](mailbox_completion_zsh.md) - Generate the autocompletion script for zsh - -###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_completion_bash.md b/cmd/docs/mailbox_completion_bash.md deleted file mode 100644 index 0c8b728..0000000 --- a/cmd/docs/mailbox_completion_bash.md +++ /dev/null @@ -1,50 +0,0 @@ -## mailbox completion bash - -Generate the autocompletion script for bash - -### Synopsis - -Generate the autocompletion script for the bash shell. - -This script depends on the 'bash-completion' package. -If it is not installed already, you can install it via your OS's package manager. - -To load completions in your current shell session: - - source <(mailbox completion bash) - -To load completions for every new session, execute once: - -#### Linux: - - mailbox completion bash > /etc/bash_completion.d/mailbox - -#### macOS: - - mailbox completion bash > $(brew --prefix)/etc/bash_completion.d/mailbox - -You will need to start a new shell for this setup to take effect. - - -``` -mailbox completion bash -``` - -### Options - -``` - -h, --help help for bash - --no-descriptions disable completion descriptions -``` - -### Options inherited from parent commands - -``` - --config string config file (default is $HOME/.mailbox.yaml) -``` - -### SEE ALSO - -* [mailbox completion](mailbox_completion.md) - Generate the autocompletion script for the specified shell - -###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_completion_fish.md b/cmd/docs/mailbox_completion_fish.md deleted file mode 100644 index 0cc3de6..0000000 --- a/cmd/docs/mailbox_completion_fish.md +++ /dev/null @@ -1,41 +0,0 @@ -## mailbox completion fish - -Generate the autocompletion script for fish - -### Synopsis - -Generate the autocompletion script for the fish shell. - -To load completions in your current shell session: - - mailbox completion fish | source - -To load completions for every new session, execute once: - - mailbox completion fish > ~/.config/fish/completions/mailbox.fish - -You will need to start a new shell for this setup to take effect. - - -``` -mailbox completion fish [flags] -``` - -### Options - -``` - -h, --help help for fish - --no-descriptions disable completion descriptions -``` - -### Options inherited from parent commands - -``` - --config string config file (default is $HOME/.mailbox.yaml) -``` - -### SEE ALSO - -* [mailbox completion](mailbox_completion.md) - Generate the autocompletion script for the specified shell - -###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_completion_powershell.md b/cmd/docs/mailbox_completion_powershell.md deleted file mode 100644 index 1116fe6..0000000 --- a/cmd/docs/mailbox_completion_powershell.md +++ /dev/null @@ -1,38 +0,0 @@ -## mailbox completion powershell - -Generate the autocompletion script for powershell - -### Synopsis - -Generate the autocompletion script for powershell. - -To load completions in your current shell session: - - mailbox completion powershell | Out-String | Invoke-Expression - -To load completions for every new session, add the output of the above command -to your powershell profile. - - -``` -mailbox completion powershell [flags] -``` - -### Options - -``` - -h, --help help for powershell - --no-descriptions disable completion descriptions -``` - -### Options inherited from parent commands - -``` - --config string config file (default is $HOME/.mailbox.yaml) -``` - -### SEE ALSO - -* [mailbox completion](mailbox_completion.md) - Generate the autocompletion script for the specified shell - -###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/docs/mailbox_completion_zsh.md b/cmd/docs/mailbox_completion_zsh.md deleted file mode 100644 index fedd87b..0000000 --- a/cmd/docs/mailbox_completion_zsh.md +++ /dev/null @@ -1,52 +0,0 @@ -## mailbox completion zsh - -Generate the autocompletion script for zsh - -### Synopsis - -Generate the autocompletion script for the zsh shell. - -If shell completion is not already enabled in your environment you will need -to enable it. You can execute the following once: - - echo "autoload -U compinit; compinit" >> ~/.zshrc - -To load completions in your current shell session: - - source <(mailbox completion zsh) - -To load completions for every new session, execute once: - -#### Linux: - - mailbox completion zsh > "${fpath[1]}/_mailbox" - -#### macOS: - - mailbox completion zsh > $(brew --prefix)/share/zsh/site-functions/_mailbox - -You will need to start a new shell for this setup to take effect. - - -``` -mailbox completion zsh [flags] -``` - -### Options - -``` - -h, --help help for zsh - --no-descriptions disable completion descriptions -``` - -### Options inherited from parent commands - -``` - --config string config file (default is $HOME/.mailbox.yaml) -``` - -### SEE ALSO - -* [mailbox completion](mailbox_completion.md) - Generate the autocompletion script for the specified shell - -###### Auto generated by spf13/cobra on 8-Feb-2026 diff --git a/cmd/server.go b/cmd/server.go index a3d6d7a..dce2d7f 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -91,6 +91,10 @@ func door(w http.ResponseWriter, r *http.Request) { v, _ := url.ParseQuery(r.Header.Get("X-Args")) match := servercExpr.FindStringSubmatch(r.Header.Get("X-Uri")) + if match == nil || len(match) < 2 { + w.WriteHeader(http.StatusNotFound) + return + } if match[1] == "door" { if v.Get("t") != "a" { log.Println("No `t`", v.Get("t")) diff --git a/goclean.sh b/goclean.sh index ec5fa3c..097f2be 100644 --- a/goclean.sh +++ b/goclean.sh @@ -1,53 +1,9 @@ #!/bin/bash -# The script does automatic checking on a Go package and its sub-packages, including: -# 1. gofmt (http://golang.org/cmd/gofmt/) -# 2. goimports (https://github.com/bradfitz/goimports) -# 3. golint (https://github.com/golang/lint) -# 4. go vet (http://golang.org/cmd/vet) -# 5. race detector (http://blog.golang.org/race-detector) -# 6. test coverage (http://blog.golang.org/cover) +# Local checks: go vet, test coverage (requires MariaDB - run sh ./dev-run-mariadb.sh first) # ref. https://gist.github.com/hailiang/0f22736320abe6be71ce set -e -#go get -v golang.org/x/lint/golint -#go get -v golang.org/x/tools/cmd/goimports -#go get -v ./... - -# Automatic checks -#test -z "$(gofmt -l -w ./ | tee /dev/stderr)" -#test -z "$(goimports -l -w ./ | tee /dev/stderr)" -#test -z "$(golint ./... | tee /dev/stderr)" - -#go vet ./... -#go test -race ./... - -#go test -race ./campaign... -#go test -race ./mails... -#go test -race ./reader... -#go test -race ./utils... - -# Run test coverage on each subdirectories and merge the coverage profile. - -echo "mode: count" > coverage.cov - -ls -# Standard go tooling behavior is to ignore dirs with leading underscors -for dir in $(find . -maxdepth 10 -not -path './.git*' -not -path '*/_*' -not -path './cmd' -not -path './cmd/docs' -not -path './sql' -type d); -do -if ls $dir/*.go &> /dev/null; then - go test -v -covermode=count -coverprofile=$dir/coverage.tmp $dir - if [ -f $dir/coverage.tmp ] - then - cat $dir/coverage.tmp | tail -n +2 >> coverage.cov - rm $dir/coverage.tmp - fi -fi -done - -go tool cover -func coverage.cov -cat coverage.cov - -# To submit the test coverage result to coveralls.io, -# use goveralls (https://github.com/mattn/goveralls) -# goveralls -covercoverage=coverage.cov -service=travis-ci +go vet ./... +go test -coverprofile=coverage.out -covermode=atomic ./... +go tool cover -func=coverage.out diff --git a/reader/reader.go b/reader/reader.go index be599ac..c857958 100644 --- a/reader/reader.go +++ b/reader/reader.go @@ -1,8 +1,15 @@ package reader -import "github.com/toomore/mailbox/utils" +import ( + "log" + + "github.com/toomore/mailbox/utils" +) // Save is to save read record func Save(cid, uid, ip, agent string) { - utils.GetConn().Query(`INSERT INTO reader(cid,uid,ip,agent) VALUES(?,?,?,?)`, cid, uid, ip, agent) + _, err := utils.GetConn().Exec(`INSERT INTO reader(cid,uid,ip,agent) VALUES(?,?,?,?)`, cid, uid, ip, agent) + if err != nil { + log.Println("[reader][Save] ", err) + } } diff --git a/reader/reader_test.go b/reader/reader_test.go new file mode 100644 index 0000000..03a46c5 --- /dev/null +++ b/reader/reader_test.go @@ -0,0 +1,13 @@ +package reader + +import ( + "testing" + + _ "github.com/go-sql-driver/mysql" +) + +func TestSave(t *testing.T) { + // Smoke test: Save does not panic when called with valid args. + // Requires MariaDB (run sh ./dev-run-mariadb.sh). + Save("00000001", "1", "127.0.0.1", "test-agent") +} From 13d65e11a40b87cb7354a88aa97e41a3a8b85039 Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Sun, 8 Feb 2026 03:06:41 +0800 Subject: [PATCH 16/19] chore: upgrade to Go 1.25 and update dependencies - go.mod: go 1.25, toolchain go1.25.7 - CI: go-version '1.25' in go.yml - Docker: golang:1.25-alpine3.22 (Dockerfile, build-base, dev-run-docker) - deps: aws-sdk-go v1.55.8, mysql v1.9.3, cobra v1.10.2, viper v1.21.0 Co-authored-by: Cursor --- .github/workflows/go.yml | 2 +- Dockerfile | 2 +- build-base.sh | 2 +- dev-run-docker.sh | 2 +- go.mod | 41 ++++++++------------ go.sum | 83 +++++++++++++++++----------------------- 6 files changed, 56 insertions(+), 76 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 0214a8b..7280786 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -39,7 +39,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: 1.22.2 + go-version: '1.25' - name: Build run: go build -v ./... diff --git a/Dockerfile b/Dockerfile index 64c20df..be24b24 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22.2-alpine3.19 +FROM golang:1.25-alpine3.22 WORKDIR /go/src/github.com/toomore/mailbox/ diff --git a/build-base.sh b/build-base.sh index f2b49bd..c579abd 100644 --- a/build-base.sh +++ b/build-base.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash docker pull alpine:3.20.3 -docker pull golang:1.22.2-alpine3.20 +docker pull golang:1.25-alpine3.22 docker build -t toomore/mailbox:base ./ diff --git a/dev-run-docker.sh b/dev-run-docker.sh index 7b29f14..9743f96 100644 --- a/dev-run-docker.sh +++ b/dev-run-docker.sh @@ -13,4 +13,4 @@ docker run -it --rm --link mailbox-mariadb:MARIADB \ -e "mailbox_ses_sender=???" \ -e "mailbox_ses_replyto=???" \ -e "mailbox_web_site=???" \ - golang:1.22.2-alpine3.20 bash + golang:1.25-alpine3.22 bash diff --git a/go.mod b/go.mod index 73619e3..d299863 100644 --- a/go.mod +++ b/go.mod @@ -1,42 +1,35 @@ module github.com/toomore/mailbox -go 1.22.0 +go 1.25 -toolchain go1.22.2 +toolchain go1.25.7 require ( - github.com/aws/aws-sdk-go v1.55.5 - github.com/go-sql-driver/mysql v1.8.1 + github.com/aws/aws-sdk-go v1.55.8 + github.com/go-sql-driver/mysql v1.9.3 github.com/mitchellh/go-homedir v1.1.0 - github.com/spf13/cobra v1.8.1 - github.com/spf13/viper v1.19.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 ) require ( filippo.io/edwards25519 v1.1.0 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect - github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect - github.com/magiconair/properties v1.8.9 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/sagikazarmark/locafero v0.6.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.7.1 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect - go.uber.org/multierr v1.11.0 // indirect - golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6395a3a..a6d46af 100644 --- a/go.sum +++ b/go.sum @@ -1,24 +1,23 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= -github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= +github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ= +github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= -github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= @@ -32,55 +31,43 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= -github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= -github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= -github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= -github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo= -golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= From 8794903fc98aec5f78adfd2a01f286bb9b922d4f Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Sun, 8 Feb 2026 03:12:45 +0800 Subject: [PATCH 17/19] chore: pin toolchain to go1.25.6 for GitHub Actions compatibility Co-authored-by: Cursor --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index d299863..35f33a0 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/toomore/mailbox go 1.25 -toolchain go1.25.7 +toolchain go1.25.6 require ( github.com/aws/aws-sdk-go v1.55.8 From 3f0d1938d8ff4eeaba2060d276df5f2e72ce8591 Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Fri, 27 Mar 2026 22:44:07 +0800 Subject: [PATCH 18/19] feat: add List-Unsubscribe header and manual unsubscribe CLI (#13) Phase 1 of unsubscribe mechanism: enable mail clients to show built-in unsubscribe button via List-Unsubscribe mailto header, and provide CLI tools for manual recipient opt-out. - Switch SES from SendEmail to SendRawEmail to support custom headers - Add List-Unsubscribe / List-Unsubscribe-Post headers (env-driven) - Add `user unsubscribe` command for single/batch marking (alive=0) - Add `user unsubscribed` command to list opted-out recipients - Update README with new env vars and Phase 1 workflow docs Made-with: Cursor --- README.md | 19 ++++++++ cmd/README.md | 2 + cmd/user.go | 104 +++++++++++++++++++++++++++++++++++++++++--- cmd/user_test.go | 46 ++++++++++++++++++++ mails/mails.go | 89 ++++++++++++++++++++++++++++++++++++- mails/mails_test.go | 45 +++++++++++++++++++ 6 files changed, 298 insertions(+), 7 deletions(-) create mode 100644 cmd/user_test.go diff --git a/README.md b/README.md index 9a6e3d6..abd6164 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ sh ./dev-run-mariadb-client.sh # MariaDB 客戶端 | `mailbox_ses_sender` | 發件者信箱,格式:`Name ` | | `mailbox_web_site` | 追蹤連結網域(不含 https 與結尾斜線),如:`open.example.com` | | `mailbox_ses_replyto` | (選用)回信信箱 | +| `mailbox_unsubscribe_mailto` | (選用)List-Unsubscribe 的 mailto 信箱,未設定時 fallback `mailbox_ses_replyto` | +| `mailbox_unsubscribe_one_click` | (選用)`true`/`1` 時加上 `List-Unsubscribe-Post: List-Unsubscribe=One-Click` | | `MAILBOX_DB_DSN` | (選用)資料庫連線字串,覆寫預設 DSN | --- @@ -86,6 +88,8 @@ mailbox user import ./list.csv # 匯入訂閱者(CSV 需含 email, groups, mailbox user import ./list.csv -d # 預覽模式(dry run) mailbox user update ./list.csv # 更新訂閱者(需含 alive 欄位) mailbox user show [group] # 顯示群組使用者 +mailbox user unsubscribe --email user@example.com --group weekly --reason "gmail unsub" +mailbox user unsubscribed [group] # 顯示群組已退訂(alive=0)名單 ``` ### Send @@ -101,6 +105,21 @@ mailbox send -p [html] -t [text] -s "Subject" --uid="6,12" --cid [cid] mailbox send -p [html] -t [text] -s "Subject" -g [group] --cid [cid] -d ``` +### Unsubscribe(Phase 1: manual) + +```bash +# 建議:設定退訂信箱(此信箱接收郵件客戶端退訂通知) +export mailbox_unsubscribe_mailto="sender+unsubscribe@example.com" + +# 可選:提示客戶端 one-click 退訂(仍為人工處理流程) +export mailbox_unsubscribe_one_click="true" +``` + +- 發信時會自動加上 `List-Unsubscribe` header(mailto)。 +- 收到退訂通知後,使用 `mailbox user unsubscribe` 或 `mailbox user update`(`alive=0`)手動標記。 +- 批次處理可準備含 `email,groups,f_name,l_name,alive` 欄位的 CSV,透過 `mailbox user update ./list.csv` 一次更新。 +- `send` 僅會寄給 `alive=1` 使用者,已退訂名單會自動排除。 + ### Server ```bash diff --git a/cmd/README.md b/cmd/README.md index 0c83955..6389aaa 100644 --- a/cmd/README.md +++ b/cmd/README.md @@ -14,6 +14,8 @@ CMD ### `mailbox user` 匯入訂閱者的資訊。 +- `mailbox user unsubscribe --email ... [--group ...]` 可手動標記退訂(alive=0)。 +- `mailbox user unsubscribed [group]` 可查詢指定群組已退訂名單。 ### `mailbox send` 發送電子報,以 **HTML** 格式發送。 diff --git a/cmd/user.go b/cmd/user.go index 9b3c933..bb14b2a 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -38,6 +38,9 @@ import ( var ( userDryRun *bool userConn *sql.DB + userEmails *[]string + userGroup *string + userReason *string ) type user struct { @@ -146,9 +149,13 @@ func updateUser(data []user) { } func readUser(group string) { - rows, err := userConn.Query(`SELECT id,email,email_uni,f_name,l_name,alive,created FROM user WHERE alive=1 AND groups=?`, group) + readUserWithAlive(group, 1) +} + +func readUserWithAlive(group string, alive int) int { + rows, err := userConn.Query(`SELECT id,email,email_uni,f_name,l_name,alive,created FROM user WHERE alive=? AND groups=?`, alive, group) if err != nil { - log.Fatal("[cmd][readUser][Query]", err) + log.Fatal("[cmd][readUserWithAlive][Query]", err) } defer rows.Close() var ( @@ -158,19 +165,69 @@ func readUser(group string) { fname string lname string created time.Time - alive int + userAlive int ) + count := 0 w := tabwriter.NewWriter(os.Stdout, 0, 0, 0, ' ', tabwriter.AlignRight|tabwriter.Debug) fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", "id", "email", "email_uni", "fname", "lname", "alive", "created") for rows.Next() { - if err := rows.Scan(&id, &email, &email_uni, &fname, &lname, &alive, &created); err != nil { + if err := rows.Scan(&id, &email, &email_uni, &fname, &lname, &userAlive, &created); err != nil { log.Println(err) } else { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\t%s\n", id, email, email_uni, fname, lname, alive, created) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\t%s\n", id, email, email_uni, fname, lname, userAlive, created) + count++ } } fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", "id", "email", "email_uni", "fname", "lname", "alive", "created") w.Flush() + return count +} + +func formattedEmails(values []string) []string { + seen := map[string]struct{}{} + results := []string{} + for _, item := range values { + for _, p := range strings.Split(item, ",") { + v := utils.FormatEmail(strings.TrimSpace(p)) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + results = append(results, v) + } + } + return results +} + +func buildUnsubscribeUpdateQuery(emails []string, group string) (string, []interface{}) { + placeholders := make([]string, len(emails)) + args := make([]interface{}, 0, len(emails)+1) + for i, v := range emails { + placeholders[i] = "?" + args = append(args, v) + } + query := `UPDATE user SET alive=0 WHERE email_uni IN (` + strings.Join(placeholders, ",") + `)` + if group != "" { + query += ` AND groups=?` + args = append(args, group) + } + return query, args +} + +func unsubscribeUsers(emails []string, group string) { + if len(emails) == 0 { + log.Fatal("[cmd][unsubscribeUsers] please provide --email") + } + query, args := buildUnsubscribeUpdateQuery(emails, group) + result, err := userConn.Exec(query, args...) + if err != nil { + log.Fatal("[cmd][unsubscribeUsers][Exec]", err) + } + rowAff, _ := result.RowsAffected() + log.Printf("[UNSUBSCRIBE] RowsAffected %d emails=%v group=%q reason=%q", rowAff, emails, group, *userReason) } var userCmd = &cobra.Command{ @@ -247,9 +304,44 @@ var showCmd = &cobra.Command{ }, } +var unsubscribeCmd = &cobra.Command{ + Use: "unsubscribe", + Short: "Mark users as unsubscribed", + Long: "手動標記退訂(alive=0),支援 email 與 group 條件。", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + userConn = utils.GetConn() + }, + Run: func(cmd *cobra.Command, args []string) { + unsubscribeUsers(formattedEmails(*userEmails), *userGroup) + }, +} + +var unsubscribedCmd = &cobra.Command{ + Use: "unsubscribed [groups ...]", + Short: "Show unsubscribed users", + Long: "顯示指定群組的退訂名單(alive=0)。", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + userConn = utils.GetConn() + }, + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + cmd.Help() + return + } + for _, g := range args { + fmt.Printf("----- %s (alive=0) -----\n", g) + count := readUserWithAlive(g, 0) + fmt.Printf("total unsubscribed: %d\n", count) + } + }, +} + func init() { userDryRun = userCmd.PersistentFlags().BoolP("dryRun", "d", false, "Dry run read csv data") + userEmails = unsubscribeCmd.Flags().StringSlice("email", []string{}, "unsubscribe target email, supports repeated/comma-separated values") + userGroup = unsubscribeCmd.Flags().String("group", "", "group filter") + userReason = unsubscribeCmd.Flags().String("reason", "", "manual unsubscribe reason (for logging)") RootCmd.AddCommand(userCmd) - userCmd.AddCommand(importCmd, showCmd, updateCmd) + userCmd.AddCommand(importCmd, showCmd, updateCmd, unsubscribeCmd, unsubscribedCmd) } diff --git a/cmd/user_test.go b/cmd/user_test.go new file mode 100644 index 0000000..f98c946 --- /dev/null +++ b/cmd/user_test.go @@ -0,0 +1,46 @@ +package cmd + +import "testing" + +func TestFormattedEmails(t *testing.T) { + in := []string{ + " Foo.Bar+promo@example.com,foo.bar@example.com ", + "foo.bar+newsletter@example.com", + "bar@example.com", + } + got := formattedEmails(in) + if len(got) != 2 { + t.Fatalf("expected 2 unique emails, got %d (%v)", len(got), got) + } + if got[0] != "foobar@example.com" { + t.Fatalf("unexpected first email: %s", got[0]) + } + if got[1] != "bar@example.com" { + t.Fatalf("unexpected second email: %s", got[1]) + } +} + +func TestBuildUnsubscribeUpdateQueryWithoutGroup(t *testing.T) { + query, args := buildUnsubscribeUpdateQuery([]string{"a@example.com", "b@example.com"}, "") + expectQuery := "UPDATE user SET alive=0 WHERE email_uni IN (?,?)" + if query != expectQuery { + t.Fatalf("unexpected query: %s", query) + } + if len(args) != 2 { + t.Fatalf("unexpected args length: %d", len(args)) + } +} + +func TestBuildUnsubscribeUpdateQueryWithGroup(t *testing.T) { + query, args := buildUnsubscribeUpdateQuery([]string{"a@example.com"}, "weekly") + expectQuery := "UPDATE user SET alive=0 WHERE email_uni IN (?) AND groups=?" + if query != expectQuery { + t.Fatalf("unexpected query: %s", query) + } + if len(args) != 2 { + t.Fatalf("unexpected args length: %d", len(args)) + } + if args[1] != "weekly" { + t.Fatalf("unexpected group arg: %v", args[1]) + } +} diff --git a/mails/mails.go b/mails/mails.go index 42bf299..b90aa3d 100644 --- a/mails/mails.go +++ b/mails/mails.go @@ -4,7 +4,10 @@ import ( "database/sql" "fmt" "log" + "mime" + "net/mail" "os" + "strings" "sync" "github.com/aws/aws-sdk-go/aws" @@ -66,10 +69,94 @@ func GenParams(to string, message string, subject string, text string) *ses.Send return params } +func getUnsubscribeMailto() string { + if v := strings.TrimSpace(os.Getenv("mailbox_unsubscribe_mailto")); v != "" { + return v + } + return strings.TrimSpace(os.Getenv("mailbox_ses_replyto")) +} + +func buildListUnsubscribeHeaders() []string { + mailto := getUnsubscribeMailto() + if mailto == "" { + return nil + } + headers := []string{ + fmt.Sprintf("List-Unsubscribe: ", mailto), + } + if strings.EqualFold(strings.TrimSpace(os.Getenv("mailbox_unsubscribe_one_click")), "true") || + strings.TrimSpace(os.Getenv("mailbox_unsubscribe_one_click")) == "1" { + headers = append(headers, "List-Unsubscribe-Post: List-Unsubscribe=One-Click") + } + return headers +} + +func encodeSubject(subject string) string { + if subject == "" { + return "" + } + return mime.BEncoding.Encode("UTF-8", subject) +} + +func buildRawEmail(params *ses.SendEmailInput) []byte { + var ( + to = strings.TrimSpace(aws.StringValue(params.Destination.ToAddresses[0])) + from = strings.TrimSpace(aws.StringValue(params.Source)) + subject = aws.StringValue(params.Message.Subject.Data) + html = aws.StringValue(params.Message.Body.Html.Data) + text = aws.StringValue(params.Message.Body.Text.Data) + ) + boundary := "mailbox_alt_boundary" + headers := []string{ + fmt.Sprintf("From: %s", from), + fmt.Sprintf("To: %s", to), + fmt.Sprintf("Subject: %s", encodeSubject(subject)), + "MIME-Version: 1.0", + fmt.Sprintf("Content-Type: multipart/alternative; boundary=%q", boundary), + } + if len(params.ReplyToAddresses) > 0 { + reply := strings.TrimSpace(aws.StringValue(params.ReplyToAddresses[0])) + if reply != "" { + headers = append(headers, fmt.Sprintf("Reply-To: %s", reply)) + } + } + headers = append(headers, buildListUnsubscribeHeaders()...) + + var msg strings.Builder + msg.WriteString(strings.Join(headers, "\r\n")) + msg.WriteString("\r\n\r\n") + msg.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") + msg.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n") + msg.WriteString(text) + msg.WriteString("\r\n") + msg.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n") + msg.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n") + msg.WriteString(html) + msg.WriteString("\r\n") + msg.WriteString(fmt.Sprintf("--%s--\r\n", boundary)) + return []byte(msg.String()) +} + // Send is to send mail func Send(params *ses.SendEmailInput) { + // Validate common address formats before passing to SES raw API. + to := strings.TrimSpace(aws.StringValue(params.Destination.ToAddresses[0])) + from := strings.TrimSpace(aws.StringValue(params.Source)) + if _, err := mail.ParseAddress(to); err != nil { + log.Println(to, err) + return + } + if _, err := mail.ParseAddress(from); err != nil { + log.Println(from, err) + return + } + raw := buildRawEmail(params) for i := 0; i < 5; i++ { - resp, err := svc.SendEmail(params) + resp, err := svc.SendRawEmail(&ses.SendRawEmailInput{ + RawMessage: &ses.RawMessage{Data: raw}, + }) if err == nil { log.Println(*params.Destination.ToAddresses[0], resp) return diff --git a/mails/mails_test.go b/mails/mails_test.go index 591fc24..dbd6e6b 100644 --- a/mails/mails_test.go +++ b/mails/mails_test.go @@ -2,6 +2,8 @@ package mails import ( "fmt" + "os" + "strings" "testing" "github.com/toomore/mailbox/campaign" @@ -12,6 +14,49 @@ func TestGenParams(t *testing.T) { t.Logf("%+v", GenParams("toomore0929@gmail.com", "message", "[Test]", "text message")) } +func TestBuildListUnsubscribeHeaders(t *testing.T) { + t.Setenv("mailbox_unsubscribe_mailto", "sender+unsubscribe@example.com") + t.Setenv("mailbox_unsubscribe_one_click", "true") + headers := buildListUnsubscribeHeaders() + if len(headers) != 2 { + t.Fatalf("unexpected header count: %d", len(headers)) + } + if headers[0] != "List-Unsubscribe: " { + t.Fatalf("unexpected List-Unsubscribe header: %s", headers[0]) + } + if headers[1] != "List-Unsubscribe-Post: List-Unsubscribe=One-Click" { + t.Fatalf("unexpected List-Unsubscribe-Post header: %s", headers[1]) + } +} + +func TestBuildRawEmailContainsHeaders(t *testing.T) { + t.Setenv("mailbox_ses_sender", "Sender ") + t.Setenv("mailbox_ses_replyto", "sender+reply@example.com") + t.Setenv("mailbox_unsubscribe_mailto", "sender+unsubscribe@example.com") + t.Setenv("mailbox_unsubscribe_one_click", "1") + params := GenParams("User ", "Hello", "測試 Subject", "Hello") + raw := string(buildRawEmail(params)) + for _, expected := range []string{ + "Reply-To: sender+reply@example.com", + "List-Unsubscribe: ", + "List-Unsubscribe-Post: List-Unsubscribe=One-Click", + "Content-Type: multipart/alternative", + } { + if !strings.Contains(raw, expected) { + t.Fatalf("raw email missing %q", expected) + } + } +} + +func TestGetUnsubscribeMailtoFallbackReplyTo(t *testing.T) { + os.Unsetenv("mailbox_unsubscribe_mailto") + t.Setenv("mailbox_ses_replyto", "sender+unsubscribe@example.com") + got := getUnsubscribeMailto() + if got != "sender+unsubscribe@example.com" { + t.Fatalf("unexpected fallback value: %s", got) + } +} + func TestProcessSend(t *testing.T) { stmt, err := utils.GetConn().Prepare(`INSERT INTO user(email,email_uni,groups,f_name,l_name) VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE f_name=?, l_name=?`) From 9e932eca468b2f84a6cb492d465871e73d425221 Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Fri, 27 Mar 2026 22:46:52 +0800 Subject: [PATCH 19/19] chore: update dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - filippo.io/edwards25519 v1.1.0 → v1.2.0 - pelletier/go-toml/v2 v2.2.4 → v2.3.0 - golang.org/x/sys v0.40.0 → v0.42.0 - golang.org/x/text v0.33.0 → v0.35.0 Made-with: Cursor --- go.mod | 10 +++++----- go.sum | 16 ++++++++-------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/go.mod b/go.mod index 35f33a0..925b190 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/toomore/mailbox -go 1.25 +go 1.25.0 toolchain go1.25.6 @@ -13,13 +13,13 @@ require ( ) require ( - filippo.io/edwards25519 v1.1.0 // indirect + filippo.io/edwards25519 v1.2.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect @@ -28,8 +28,8 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index a6d46af..7796645 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ= github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -33,8 +33,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= @@ -61,10 +61,10 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=