diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 4228749..7280786 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: | @@ -33,28 +33,32 @@ 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 with: - go-version: 1.22.2 + go-version: '1.25' - 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 + run: | sh ./build-base.sh sh ./build-min.sh docker images 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/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/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/README.md b/README.md index f932c38..abd6164 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,157 @@ -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_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 | + +--- + +## 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] # 顯示群組使用者 +mailbox user unsubscribe --email user@example.com --group weekly --reason "gmail unsub" +mailbox user unsubscribed [group] # 顯示群組已退訂(alive=0)名單 +``` + +### 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 +``` + +### 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 +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) 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/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/README.md b/cmd/README.md index 445035e..6389aaa 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`。 @@ -14,8 +14,10 @@ CMD ### `mailbox user` 匯入訂閱者的資訊。 +- `mailbox user unsubscribe --email ... [--group ...]` 可手動標記退訂(alive=0)。 +- `mailbox user unsubscribed [group]` 可查詢指定群組已退訂名單。 -### `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_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 ...") } }, diff --git a/cmd/send.go b/cmd/send.go index fa67d25..b407008 100644 --- a/cmd/send.go +++ b/cmd/send.go @@ -22,10 +22,10 @@ package cmd import ( "database/sql" - "fmt" - "io/ioutil" + "io" "log" "os" + "strconv" "strings" "github.com/spf13/cobra" @@ -39,6 +39,7 @@ var ( sendGroups *string sendLimit *int sendPath *string + sendTextPath *string sendReplaceLink *bool sendSubject *string sendUID *string @@ -60,26 +61,47 @@ 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, ",") + 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) } + if rows != nil { + defer rows.Close() + } - mails.ProcessSend(body, rows, *sendCID, *sendReplaceLink, *sendSubject, *sendDryRun, *sendLimit) + mails.ProcessSend(body, body_text, rows, *sendCID, *sendReplaceLink, *sendSubject, *sendDryRun, *sendLimit) }, } @@ -89,6 +111,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/server.go b/cmd/server.go index 501b7e8..dce2d7f 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) { @@ -68,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 ( @@ -88,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")) @@ -112,16 +119,29 @@ 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) - serverlinksCache[serverlinksCacheKey] = url - 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() } } diff --git a/cmd/user.go b/cmd/user.go index c1a8f95..bb14b2a 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -38,14 +38,18 @@ import ( var ( userDryRun *bool userConn *sql.DB + userEmails *[]string + userGroup *string + userReason *string ) 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 +67,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 +95,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,65 +113,121 @@ 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) } + defer rows.Close() var c int for rows.Next() { 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) - defer rows.Close() + 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 ( - 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 + 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\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, &userAlive, &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, userAlive, created) + count++ } } - 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() + 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{ @@ -243,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/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..925b190 100644 --- a/go.mod +++ b/go.mod @@ -1,42 +1,35 @@ module github.com/toomore/mailbox -go 1.22.0 +go 1.25.0 -toolchain go1.22.2 +toolchain go1.25.6 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 + 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/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.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.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.42.0 // indirect + golang.org/x/text v0.35.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..7796645 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= +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= +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.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/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.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= -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= 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/mails/mails.go b/mails/mails.go index 4541717..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" @@ -39,6 +42,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 +57,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"))} @@ -61,10 +69,94 @@ func GenParams(to string, message string, subject string) *ses.SendEmailInput { 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 @@ -81,7 +173,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 +187,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 +195,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 +238,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..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" @@ -9,16 +11,59 @@ 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 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,groups,f_name,l_name) - VALUES(?,?,?,?) ON DUPLICATE KEY UPDATE 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 { @@ -28,6 +73,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, @@ -36,7 +82,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) @@ -44,6 +90,7 @@ func TestProcessSend(t *testing.T) { // Test Run ProcessSend( []byte(`12`), + []byte(` {{WASHI}}https://toomore.net/{{/WASHI}}`), rows, fmt.Sprintf("%x", cid), true, diff --git a/mails/replace.go b/mails/replace.go index b366a40..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() @@ -122,6 +125,5 @@ func filteratags(rg *regexp.Regexp, body *[]byte, cid string) map[string]LinksDa }(v[1]) } wg.Wait() - conn.Close() return result } 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") +} 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..82d2e3f 100644 --- a/sql/tables.sql +++ b/sql/tables.sql @@ -1,72 +1,68 @@ -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'; + 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, + created DATETIME DEFAULT NOW(), + updated DATETIME DEFAULT NOW() ON UPDATE NOW(), + alive bool DEFAULT TRUE, + PRIMARY KEY (id), + UNIQUE KEY email_groups(email_uni, 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'; diff --git a/utils/utils.go b/utils/utils.go index 62a2a9d..943f64e 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -6,6 +6,10 @@ import ( "crypto/sha256" "database/sql" "log" + "os" + "regexp" + "strings" + "sync" ) const ( @@ -13,6 +17,19 @@ const ( SQLPATH = "root:mailboxdbs@tcp(MARIADB:3306)/mailbox?parseTime=true" ) +var ( + dbOnce sync.Once + 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) @@ -29,15 +46,33 @@ 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) + dbOnce.Do(func() { + var err error + dbInstance, err = sql.Open("mysql", getDSN()) + 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 +func FormatEmail(email string) string { + re := regexp.MustCompile(`\+[^@]*`) + 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, ".", "") - return conn + return localPart + "@" + domain } diff --git a/utils/utils_test.go b/utils/utils_test.go index d07372c..6913cf8 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,27 @@ 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 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") + } +} + func BenchmarkGenSeed(b *testing.B) { for i := 0; i < b.N; i++ { GenSeed()