feat(mail): 增加签名管理与客户端配置
- 新增邮件签名的数据表、接口和前端管理页,支持全局/邮箱默认签名的创建、编辑、删除与查询。 - 写信时自动带入当前邮箱的默认签名,并优化手动输入内容时的覆盖行为。 - 补充第三方邮件客户端配置页,展示 IMAP/POP3/SMTP 连接信息及公共主机名。 - 扩展部署配置,开放 POP3S/SMTPS 端口并启用 Dovecot POP3 服务。
This commit is contained in:
@@ -232,6 +232,16 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, email)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS mail_signatures (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mailbox_id TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS mail_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -275,6 +285,7 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
PRIMARY KEY(message_id, label_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_contacts_user ON contacts(user_id, email)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_mail_signatures_user_mailbox ON mail_signatures(user_id, mailbox_id, is_default)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_mail_rules_user_mailbox ON mail_rules(user_id, mailbox_id, enabled)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_blocked_senders_user_mailbox ON blocked_senders(user_id, mailbox_id, email)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_mail_labels_mailbox ON mail_labels(mailbox_id, name)`,
|
||||
|
||||
@@ -692,6 +692,57 @@ func TestProfileAndPasswordUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserMailSignaturesDefaultResolution(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login code=%d body=%v", code, login)
|
||||
}
|
||||
domainID := mustDefaultDomainID(t, a)
|
||||
mb1 := createTestMailbox(t, admin, domainID, "signer", "Signer", "Password123!", nil)
|
||||
mb2 := createTestMailbox(t, admin, domainID, "second", "Second", "Password123!", map[string]any{"ownerEmail": mb1.Address})
|
||||
|
||||
user := &testClient{t: t, server: ts}
|
||||
if code := user.do("POST", "/api/auth/login", map[string]string{"email": mb1.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("user login code=%d", code)
|
||||
}
|
||||
var global MailSignature
|
||||
if code := user.do("POST", "/api/me/signatures", map[string]any{"name": "全局签名", "content": "Global Sig", "isDefault": true}, &global); code != http.StatusCreated || !global.IsDefault || global.MailboxID != "" {
|
||||
t.Fatalf("create global signature code=%d sig=%+v", code, global)
|
||||
}
|
||||
var bound MailSignature
|
||||
if code := user.do("POST", "/api/me/signatures", map[string]any{"mailboxId": mb1.ID, "name": "邮箱签名", "content": "Mailbox Sig", "isDefault": true}, &bound); code != http.StatusCreated || !bound.IsDefault || bound.MailboxID != mb1.ID {
|
||||
t.Fatalf("create bound signature code=%d sig=%+v", code, bound)
|
||||
}
|
||||
var defaultResp struct {
|
||||
Signature *MailSignature `json:"signature"`
|
||||
}
|
||||
if code := user.do("GET", "/api/me/signatures/default?mailboxId="+mb1.ID, nil, &defaultResp); code != http.StatusOK || defaultResp.Signature == nil || defaultResp.Signature.ID != bound.ID {
|
||||
t.Fatalf("bound default code=%d resp=%+v", code, defaultResp)
|
||||
}
|
||||
if code := user.do("GET", "/api/me/signatures/default?mailboxId="+mb2.ID, nil, &defaultResp); code != http.StatusOK || defaultResp.Signature == nil || defaultResp.Signature.ID != global.ID {
|
||||
t.Fatalf("global fallback code=%d resp=%+v", code, defaultResp)
|
||||
}
|
||||
var updated MailSignature
|
||||
if code := user.do("POST", "/api/me/signatures/"+bound.ID, map[string]any{"mailboxId": mb1.ID, "name": "更新签名", "content": "Updated Sig", "isDefault": false}, &updated); code != http.StatusOK || updated.IsDefault || updated.Content != "Updated Sig" {
|
||||
t.Fatalf("update signature code=%d sig=%+v", code, updated)
|
||||
}
|
||||
if code := user.do("GET", "/api/me/signatures/default?mailboxId="+mb1.ID, nil, &defaultResp); code != http.StatusOK || defaultResp.Signature == nil || defaultResp.Signature.ID != global.ID {
|
||||
t.Fatalf("fallback after update code=%d resp=%+v", code, defaultResp)
|
||||
}
|
||||
var ok map[string]any
|
||||
if code := user.do("DELETE", "/api/me/signatures/"+global.ID, nil, &ok); code != http.StatusOK {
|
||||
t.Fatalf("delete signature code=%d body=%v", code, ok)
|
||||
}
|
||||
if code := user.do("GET", "/api/me/signatures/default?mailboxId="+mb2.ID, nil, &defaultResp); code != http.StatusOK || defaultResp.Signature != nil {
|
||||
t.Fatalf("empty default code=%d resp=%+v", code, defaultResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserTwoFactorSetupAndLogin(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
a.cfg.TwoFactorEnabled = true
|
||||
|
||||
@@ -225,6 +225,203 @@ func (a *App) handleDeleteContact(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) handleListSignatures(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,mailbox_id,name,content,is_default,created_at,updated_at FROM mail_signatures WHERE user_id=? ORDER BY is_default DESC, updated_at DESC, created_at DESC`, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load signatures")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []MailSignature{}
|
||||
for rows.Next() {
|
||||
item, err := scanSignature(rows)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan signatures")
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a *App) handleCreateSignature(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
var req struct {
|
||||
MailboxID string `json:"mailboxId"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
mailboxID, name, content, ok := a.normalizeSignatureInput(w, r, user.ID, req.MailboxID, req.Name, req.Content)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id := newID("sig")
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save signature")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if req.IsDefault {
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE mail_signatures SET is_default=0, updated_at=? WHERE user_id=? AND mailbox_id=?`, now, user.ID, mailboxID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update default signature")
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `INSERT INTO mail_signatures(id,user_id,mailbox_id,name,content,is_default,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`,
|
||||
id, user.ID, mailboxID, name, content, boolInt(req.IsDefault), now, now); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save signature")
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save signature")
|
||||
return
|
||||
}
|
||||
item, err := a.signatureByID(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load signature")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (a *App) handleUpdateSignature(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
id := chi.URLParam(r, "id")
|
||||
_, err := a.signatureByID(r.Context(), user.ID, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(w, http.StatusNotFound, "signature not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load signature")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
MailboxID string `json:"mailboxId"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
mailboxID, name, content, ok := a.normalizeSignatureInput(w, r, user.ID, req.MailboxID, req.Name, req.Content)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update signature")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if req.IsDefault {
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE mail_signatures SET is_default=0, updated_at=? WHERE user_id=? AND mailbox_id=?`, now, user.ID, mailboxID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update default signature")
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE mail_signatures SET mailbox_id=?, name=?, content=?, is_default=?, updated_at=? WHERE id=? AND user_id=?`,
|
||||
mailboxID, name, content, boolInt(req.IsDefault), now, id, user.ID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update signature")
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update signature")
|
||||
return
|
||||
}
|
||||
item, err := a.signatureByID(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load signature")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (a *App) handleDeleteSignature(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mail_signatures WHERE id=? AND user_id=?`, chi.URLParam(r, "id"), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete signature")
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
respondError(w, http.StatusNotFound, "signature not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) handleSetDefaultSignature(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
id := chi.URLParam(r, "id")
|
||||
item, err := a.signatureByID(r.Context(), user.ID, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(w, http.StatusNotFound, "signature not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load signature")
|
||||
return
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update signature")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE mail_signatures SET is_default=0, updated_at=? WHERE user_id=? AND mailbox_id=?`, now, user.ID, item.MailboxID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update signature")
|
||||
return
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE mail_signatures SET is_default=1, updated_at=? WHERE id=? AND user_id=?`, now, id, user.ID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update signature")
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update signature")
|
||||
return
|
||||
}
|
||||
item, err = a.signatureByID(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load signature")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (a *App) handleDefaultSignature(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||
if mailboxID != "" {
|
||||
if _, err := a.mailboxForUserByID(r.Context(), user.ID, mailboxID); err != nil {
|
||||
respondError(w, http.StatusForbidden, "mailbox not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
item, err := a.defaultSignatureForMailbox(r.Context(), user.ID, mailboxID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{"signature": nil})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load signature")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"signature": item})
|
||||
}
|
||||
|
||||
func (a *App) handleListRules(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE user_id=? ORDER BY created_at DESC`, user.ID)
|
||||
@@ -561,6 +758,71 @@ func scanContact(row messageSummaryScanner) (Contact, error) {
|
||||
return item, err
|
||||
}
|
||||
|
||||
func scanSignature(row messageSummaryScanner) (MailSignature, error) {
|
||||
var item MailSignature
|
||||
var isDefault int
|
||||
var created, updated string
|
||||
err := row.Scan(&item.ID, &item.UserID, &item.MailboxID, &item.Name, &item.Content, &isDefault, &created, &updated)
|
||||
item.IsDefault = intBool(isDefault)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.UpdatedAt = parseTime(updated)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (a *App) normalizeSignatureInput(w http.ResponseWriter, r *http.Request, userID, rawMailboxID, rawName, rawContent string) (string, string, string, bool) {
|
||||
mailboxID := strings.TrimSpace(rawMailboxID)
|
||||
if mailboxID != "" {
|
||||
if _, err := a.mailboxForUserByID(r.Context(), userID, mailboxID); err != nil {
|
||||
respondError(w, http.StatusForbidden, "mailbox not found")
|
||||
return "", "", "", false
|
||||
}
|
||||
}
|
||||
name := strings.TrimSpace(rawName)
|
||||
if name == "" {
|
||||
badRequest(w, errors.New("signature name is required"))
|
||||
return "", "", "", false
|
||||
}
|
||||
if len([]rune(name)) > 80 {
|
||||
badRequest(w, errors.New("signature name is too long"))
|
||||
return "", "", "", false
|
||||
}
|
||||
content := strings.TrimSpace(rawContent)
|
||||
if content == "" {
|
||||
badRequest(w, errors.New("signature content is required"))
|
||||
return "", "", "", false
|
||||
}
|
||||
if len([]rune(content)) > 5000 {
|
||||
badRequest(w, errors.New("signature content is too long"))
|
||||
return "", "", "", false
|
||||
}
|
||||
return mailboxID, name, content, true
|
||||
}
|
||||
|
||||
func (a *App) signatureByID(ctx context.Context, userID, id string) (MailSignature, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,mailbox_id,name,content,is_default,created_at,updated_at FROM mail_signatures WHERE id=? AND user_id=?`, id, userID)
|
||||
return scanSignature(row)
|
||||
}
|
||||
|
||||
func (a *App) mailboxForUserByID(ctx context.Context, userID, mailboxID string) (*Mailbox, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at FROM mailboxes WHERE id=? AND user_id=? AND status='active'`, mailboxID, userID)
|
||||
var m Mailbox
|
||||
var created string
|
||||
if err := row.Scan(&m.ID, &m.UserID, &m.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.CreatedAt = parseTime(created)
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (a *App) defaultSignatureForMailbox(ctx context.Context, userID, mailboxID string) (MailSignature, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,mailbox_id,name,content,is_default,created_at,updated_at
|
||||
FROM mail_signatures
|
||||
WHERE user_id=? AND is_default=1 AND (mailbox_id=? OR mailbox_id='')
|
||||
ORDER BY CASE WHEN mailbox_id=? THEN 0 ELSE 1 END, updated_at DESC
|
||||
LIMIT 1`, userID, mailboxID, mailboxID)
|
||||
return scanSignature(row)
|
||||
}
|
||||
|
||||
func scanRule(row messageSummaryScanner) (MailRule, error) {
|
||||
var item MailRule
|
||||
var enabled, applyToExisting, stopProcessing int
|
||||
|
||||
@@ -44,6 +44,12 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requireAuth).Get("/me/contacts", a.handleListContacts)
|
||||
r.With(a.requireAuth).Post("/me/contacts", a.handleCreateContact)
|
||||
r.With(a.requireAuth).Delete("/me/contacts/{id}", a.handleDeleteContact)
|
||||
r.With(a.requireAuth).Get("/me/signatures", a.handleListSignatures)
|
||||
r.With(a.requireAuth).Post("/me/signatures", a.handleCreateSignature)
|
||||
r.With(a.requireAuth).Post("/me/signatures/{id}", a.handleUpdateSignature)
|
||||
r.With(a.requireAuth).Post("/me/signatures/{id}/default", a.handleSetDefaultSignature)
|
||||
r.With(a.requireAuth).Delete("/me/signatures/{id}", a.handleDeleteSignature)
|
||||
r.With(a.requireAuth).Get("/me/signatures/default", a.handleDefaultSignature)
|
||||
r.With(a.requireAuth).Get("/me/rules", a.handleListRules)
|
||||
r.With(a.requireAuth).Post("/me/rules", a.handleCreateRule)
|
||||
r.With(a.requireAuth).Delete("/me/rules/{id}", a.handleDeleteRule)
|
||||
|
||||
@@ -63,6 +63,7 @@ type PublicSettings struct {
|
||||
OpenRegistration bool `json:"openRegistration"`
|
||||
TurnstileEnabled bool `json:"turnstileEnabled"`
|
||||
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
||||
PublicHostname string `json:"publicHostname"`
|
||||
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
||||
MailRefreshMs int `json:"mailRefreshMs"`
|
||||
MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"`
|
||||
@@ -87,7 +88,7 @@ func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if refreshSeconds <= 0 {
|
||||
refreshSeconds = 30
|
||||
}
|
||||
settings := PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000}
|
||||
settings := PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, PublicHostname: a.cfg.PublicHostname, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000}
|
||||
|
||||
// Include available domains for mailbox creation during registration
|
||||
if a.cfg.OpenRegistration {
|
||||
|
||||
@@ -132,6 +132,17 @@ type Contact struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type MailSignature struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type MailRule struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user