ultisuite-backend/internal/api/mail/service.go
R3D347HR4Y bd7534658a Refactor and enhance unified frontend and API features
- Updated environment configuration to unify frontend for mail and drive under a single service.
- Revised README to reflect changes in frontend setup and routing for the unified application.
- Introduced new API documentation endpoints for better accessibility of API specifications.
- Enhanced drive and mail services with improved handling of file uploads and metadata enrichment.
- Implemented new API token management features, including creation, listing, and revocation of tokens.
- Added tests for new functionalities in drive and mail services to ensure reliability and correctness.
2026-06-07 15:44:30 +02:00

802 lines
25 KiB
Go

package mail
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
"github.com/ultisuite/ulti-backend/internal/mail/imap"
"github.com/ultisuite/ulti-backend/internal/mail/sanitize"
"github.com/ultisuite/ulti-backend/internal/mail/storage"
"github.com/ultisuite/ulti-backend/internal/mail/threading"
"github.com/ultisuite/ulti-backend/internal/securityaudit"
)
var (
ErrNotFound = errors.New("not found")
ErrUserNotProvisioned = errors.New("user not provisioned")
ErrAccountNotFound = errors.New("account not found")
ErrCredentialsUnavailable = errors.New("credentials encryption unavailable")
ErrOAuthPasswordNotAllowed = errors.New("password cannot be set on oauth account")
ErrInvalidAccountCredentials = errors.New("account credentials invalid")
ErrInvalidFolderScope = errors.New("invalid folder scope")
ErrFolderHasChildren = errors.New("folder has children")
)
type Service struct {
db *pgxpool.Pool // exported via DB() for api token handlers
credentials *credentials.Manager
audit *securityaudit.Logger
storage *storage.Client
attachmentsBucket string
driveUploader DriveUploader
logger *slog.Logger
}
func NewService(db *pgxpool.Pool, audit *securityaudit.Logger, credentialManager *credentials.Manager, objectStorage *storage.Client, attachmentsBucket string) *Service {
return &Service{
db: db,
credentials: credentialManager,
audit: audit,
storage: objectStorage,
attachmentsBucket: attachmentsBucket,
logger: slog.Default().With("component", "mail-service"),
}
}
func (s *Service) DB() *pgxpool.Pool {
return s.db
}
func (s *Service) ResolveUserID(ctx context.Context, externalID string) (string, error) {
var userID string
err := s.db.QueryRow(ctx, `SELECT id FROM users WHERE external_id = $1`, externalID).Scan(&userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrUserNotProvisioned
}
return "", err
}
return userID, nil
}
type AccountsList struct {
Accounts []map[string]any `json:"accounts"`
Pagination query.PaginationMeta `json:"pagination,omitempty"`
}
func (s *Service) ListAccounts(ctx context.Context, externalID string, params query.ListParams) (AccountsList, error) {
var total int64
err := s.db.QueryRow(ctx, `
SELECT COUNT(*) FROM mail_accounts
WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
`, externalID).Scan(&total)
if err != nil {
return AccountsList{}, err
}
rows, err := s.db.Query(ctx, `
SELECT id, name, email, provider, imap_host, smtp_host, is_active, last_sync_at, created_at
FROM mail_accounts WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
ORDER BY created_at ASC
LIMIT $2 OFFSET $3
`, externalID, params.Limit(), params.Offset())
if err != nil {
return AccountsList{}, err
}
defer rows.Close()
accounts := make([]map[string]any, 0)
for rows.Next() {
var id, name, email, provider, imapHost, smtpHost string
var isActive bool
var lastSync, createdAt any
if err := rows.Scan(&id, &name, &email, &provider, &imapHost, &smtpHost, &isActive, &lastSync, &createdAt); err != nil {
return AccountsList{}, err
}
accounts = append(accounts, map[string]any{
"id": id, "name": name, "email": email, "provider": provider,
"imap_host": imapHost, "smtp_host": smtpHost, "is_active": isActive,
"last_sync_at": lastSync, "created_at": createdAt,
})
}
if err := rows.Err(); err != nil {
return AccountsList{}, err
}
return AccountsList{
Accounts: accounts,
Pagination: params.Meta(&total),
}, nil
}
func (s *Service) CreateAccount(ctx context.Context, externalID string, req *createAccountRequest) (string, error) {
return s.CreateAccountWithCredential(ctx, externalID, req, credentials.Credential{
AuthType: credentials.AuthPassword,
Username: req.Username,
Password: req.Password,
})
}
func (s *Service) CreateAccountWithCredential(ctx context.Context, externalID string, req *createAccountRequest, cred credentials.Credential) (string, error) {
if s.credentials == nil {
return "", ErrCredentialsUnavailable
}
encrypted, err := s.credentials.EncryptCredential(cred)
if err != nil {
return "", err
}
var id string
err = s.db.QueryRow(ctx, `
INSERT INTO mail_accounts (user_id, name, email, provider, imap_host, imap_port, imap_tls, smtp_host, smtp_port, smtp_tls, credentials)
VALUES ((SELECT id FROM users WHERE external_id = $1), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id
`, externalID, req.Name, req.Email, req.Provider, req.IMAPHost, req.IMAPPort, req.IMAPTLS, req.SMTPHost, req.SMTPPort, req.SMTPTLS, encrypted).Scan(&id)
if err != nil {
return "", err
}
if err := s.bootstrapAccountDefaults(ctx, externalID, id, req.Email, req.Name); err != nil {
_, _ = s.db.Exec(ctx, `DELETE FROM mail_accounts WHERE id = $1`, id)
return "", fmt.Errorf("bootstrap account defaults: %w", err)
}
return id, nil
}
func (s *Service) DeleteAccount(ctx context.Context, externalID, accountID string) error {
userID, err := s.ResolveUserID(ctx, externalID)
if err != nil {
return err
}
result, err := s.db.Exec(ctx, `DELETE FROM mail_accounts WHERE id = $1 AND user_id = $2`, accountID, userID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
if s.audit != nil {
s.audit.Log(ctx, externalID, securityaudit.ActionCriticalDeletion, map[string]any{
"target": "mail_account", "account_id": accountID,
})
}
return nil
}
type MessageListFilter struct {
Folder string
AccountID string
ScopedAccountIDs []string
}
type MessagesList struct {
Messages []map[string]any `json:"messages"`
Page int `json:"page"`
Pagination query.PaginationMeta `json:"pagination,omitempty"`
}
func (s *Service) ListMessages(ctx context.Context, externalID string, filter MessageListFilter, params query.ListParams) (MessagesList, error) {
baseQuery := `
FROM messages m
JOIN mail_accounts ma ON m.account_id = ma.id
WHERE ma.user_id = (SELECT id FROM users WHERE external_id = $1)
`
args := []any{externalID}
argIdx := 2
baseQuery, args, argIdx = appendMessageAccountScope(baseQuery, args, argIdx, filter.AccountID, filter.ScopedAccountIDs)
if clause, arg, ok := folderFilterClause(filter.Folder, argIdx); ok {
baseQuery += clause
args = append(args, arg)
argIdx++
}
var total int64
countQuery := "SELECT COUNT(*) " + baseQuery
if err := s.db.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
return MessagesList{}, err
}
listQuery := `
SELECT m.id, m.message_id, m.thread_id, m.subject, m.from_addr, m.to_addrs, m.date, m.snippet, m.flags, m.labels, m.has_attachments,
left(m.body_text, 8192), left(m.body_html, 8192)
` + baseQuery + fmt.Sprintf(" ORDER BY m.date DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
args = append(args, params.Limit(), params.Offset())
rows, err := s.db.Query(ctx, listQuery, args...)
if err != nil {
return MessagesList{}, err
}
defer rows.Close()
messages := make([]map[string]any, 0)
for rows.Next() {
var id, messageID, subject, snippet string
var threadID *string
var fromAddr, toAddrs []byte
var bodyTextSample, bodyHTMLSample string
var date any
var flags, labels []string
var hasAttachments bool
if err := rows.Scan(&id, &messageID, &threadID, &subject, &fromAddr, &toAddrs, &date, &snippet, &flags, &labels, &hasAttachments, &bodyTextSample, &bodyHTMLSample); err != nil {
return MessagesList{}, err
}
bodyTextSample, bodyHTMLSample = imap.RepairStoredBodies(bodyTextSample, bodyHTMLSample)
preview := imap.RepairSnippetWithBodies(snippet, bodyTextSample, bodyHTMLSample)
if preview == "" {
preview = imap.SnippetFromBodies(bodyTextSample, bodyHTMLSample, 200)
}
entry := map[string]any{
"id": id, "message_id": messageID,
"subject": imap.RepairSubject(subject, bodyTextSample, bodyHTMLSample, nil),
"from": json.RawMessage(fromAddr),
"to": json.RawMessage(toAddrs),
"date": date,
"snippet": preview,
"flags": flags, "labels": labels, "has_attachments": hasAttachments,
}
if threadID != nil {
entry["thread_id"] = *threadID
}
messages = append(messages, entry)
}
if err := rows.Err(); err != nil {
return MessagesList{}, err
}
return MessagesList{
Messages: messages,
Page: params.Page,
Pagination: params.Meta(&total),
}, nil
}
func (s *Service) MessageAccountID(ctx context.Context, externalID, messageID string) (string, error) {
var accountID string
err := s.db.QueryRow(ctx, `
SELECT m.account_id::text
FROM messages m
JOIN mail_accounts ma ON m.account_id = ma.id
WHERE m.id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
`, messageID, externalID).Scan(&accountID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
return "", err
}
return accountID, nil
}
func (s *Service) AttachmentAccountID(ctx context.Context, externalID, attachmentID string) (string, error) {
var accountID string
err := s.db.QueryRow(ctx, `
SELECT m.account_id::text
FROM attachments a
JOIN messages m ON a.message_id = m.id
JOIN mail_accounts ma ON m.account_id = ma.id
WHERE a.id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
`, attachmentID, externalID).Scan(&accountID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrAttachmentNotFound
}
return "", err
}
return accountID, nil
}
func (s *Service) ThreadAccessible(ctx context.Context, externalID, threadID string, scopedAccountIDs []string) (bool, error) {
if len(scopedAccountIDs) == 0 {
return false, nil
}
var ok bool
err := s.db.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM messages m
JOIN mail_accounts ma ON m.account_id = ma.id
WHERE m.thread_id = $1
AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
AND m.account_id = ANY($3)
)
`, threadID, externalID, scopedAccountIDs).Scan(&ok)
return ok, err
}
func (s *Service) GetMessage(ctx context.Context, externalID, messageID string) (map[string]any, error) {
var msg struct {
ID string
AccountID string
MessageID string
ThreadID *string
InReplyTo string
References []string
Subject string
From []byte
To []byte
Cc []byte
ReplyTo []byte
AuthInfo []byte
Date any
Text string
HTML string
Flags []string
Labels []string
}
err := s.db.QueryRow(ctx, `
SELECT m.id, m.account_id::text, m.message_id, m.thread_id, m.in_reply_to, m.references_header,
m.subject, m.from_addr, m.to_addrs, m.cc_addrs, m.reply_to, m.auth_info, m.date,
m.body_text, m.body_html, m.flags, m.labels
FROM messages m JOIN mail_accounts ma ON m.account_id = ma.id
WHERE m.id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
`, messageID, externalID).Scan(
&msg.ID, &msg.AccountID, &msg.MessageID, &msg.ThreadID, &msg.InReplyTo, &msg.References,
&msg.Subject, &msg.From, &msg.To, &msg.Cc, &msg.ReplyTo, &msg.AuthInfo, &msg.Date,
&msg.Text, &msg.HTML, &msg.Flags, &msg.Labels,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
bodyText, bodyHTML := imap.RepairStoredBodies(msg.Text, msg.HTML)
subject := imap.RepairSubject(msg.Subject, bodyText, bodyHTML, nil)
repairedSnippet := imap.RepairSnippetWithBodies(imap.SnippetFromBodies(bodyText, bodyHTML, 200), bodyText, bodyHTML)
if bodyText != msg.Text || bodyHTML != msg.HTML || subject != msg.Subject {
_, _ = s.db.Exec(ctx, `
UPDATE messages SET body_text = $1, body_html = $2, snippet = $3, subject = $4, updated_at = NOW()
WHERE id = $5
`, bodyText, bodyHTML, repairedSnippet, subject, msg.ID)
}
out := map[string]any{
"id": msg.ID, "account_id": msg.AccountID, "message_id": msg.MessageID, "subject": subject,
"from": json.RawMessage(msg.From), "to": json.RawMessage(msg.To), "cc": json.RawMessage(msg.Cc),
"reply_to": json.RawMessage(msg.ReplyTo), "auth_info": json.RawMessage(msg.AuthInfo),
"date": msg.Date, "body_text": bodyText, "body_html": sanitize.SanitizeHTML(bodyHTML),
"flags": msg.Flags, "labels": msg.Labels,
"in_reply_to": msg.InReplyTo, "references": msg.References,
}
if msg.ThreadID != nil {
out["thread_id"] = *msg.ThreadID
}
return out, nil
}
func (s *Service) UpdateLabels(ctx context.Context, externalID, messageID string, labels []string) error {
result, err := s.db.Exec(ctx, `
UPDATE messages m
SET labels = $1, updated_at = NOW()
FROM mail_accounts ma
WHERE m.id = $2
AND m.account_id = ma.id
AND ma.user_id = (SELECT id FROM users WHERE external_id = $3)
`, labels, messageID, externalID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Service) UpdateFlags(ctx context.Context, externalID, messageID string, flags []string) error {
result, err := s.db.Exec(ctx, `
UPDATE messages m
SET flags = $1, updated_at = NOW()
FROM mail_accounts ma
WHERE m.id = $2
AND m.account_id = ma.id
AND ma.user_id = (SELECT id FROM users WHERE external_id = $3)
`, flags, messageID, externalID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Service) DeleteMessage(ctx context.Context, externalID, messageID string) error {
result, err := s.db.Exec(ctx, `
DELETE FROM messages m
USING mail_accounts ma
WHERE m.id = $1
AND m.account_id = ma.id
AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
`, messageID, externalID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
if s.audit != nil {
s.audit.Log(ctx, externalID, securityaudit.ActionCriticalDeletion, map[string]any{
"target": "message", "message_id": messageID,
})
}
return nil
}
func (s *Service) GetThread(ctx context.Context, externalID, threadID string, scopedAccountIDs []string) (map[string]any, error) {
base := `
SELECT m.id, m.subject, m.from_addr, m.to_addrs, m.cc_addrs, m.date, m.snippet, m.flags, m.labels, m.has_attachments
FROM messages m JOIN mail_accounts ma ON m.account_id = ma.id
WHERE m.thread_id = $1 AND ma.user_id = (SELECT id FROM users WHERE external_id = $2)
`
args := []any{threadID, externalID}
if scopedAccountIDs != nil {
if len(scopedAccountIDs) == 0 {
return map[string]any{"thread_id": threadID, "messages": []map[string]any{}}, nil
}
base += " AND m.account_id = ANY($3)"
args = append(args, scopedAccountIDs)
}
base += " ORDER BY m.date ASC"
rows, err := s.db.Query(ctx, base, args...)
if err != nil {
return nil, err
}
defer rows.Close()
messages := make([]map[string]any, 0)
for rows.Next() {
var id, subject, snippet string
var from, toAddrs, ccAddrs []byte
var date any
var flags, labels []string
var hasAttachments bool
if err := rows.Scan(&id, &subject, &from, &toAddrs, &ccAddrs, &date, &snippet, &flags, &labels, &hasAttachments); err != nil {
return nil, err
}
messages = append(messages, map[string]any{
"id": id, "subject": subject, "from": json.RawMessage(from),
"to": json.RawMessage(toAddrs), "cc": json.RawMessage(ccAddrs),
"date": date, "snippet": snippet, "flags": flags, "labels": labels,
"has_attachments": hasAttachments,
})
}
if err := rows.Err(); err != nil {
return nil, err
}
return map[string]any{"thread_id": threadID, "messages": messages}, nil
}
type replyParent struct {
MessageID string
References []string
}
func (s *Service) loadReplyParent(ctx context.Context, userID, replyToMessageID string) (*replyParent, error) {
var parent replyParent
err := s.db.QueryRow(ctx, `
SELECT m.message_id, m.references_header
FROM messages m
JOIN mail_accounts ma ON m.account_id = ma.id
WHERE m.id = $1 AND ma.user_id = $2
`, replyToMessageID, userID).Scan(&parent.MessageID, &parent.References)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &parent, nil
}
func (s *Service) SendMessage(ctx context.Context, userID string, req *sendMessageRequest) (id, status string, err error) {
if req.IdempotencyKey != "" {
err = s.db.QueryRow(ctx, `
SELECT id, status FROM outbox
WHERE user_id = $1 AND idempotency_key = $2
AND created_at > NOW() - INTERVAL '24 hours'
ORDER BY created_at DESC
LIMIT 1
`, userID, req.IdempotencyKey).Scan(&id, &status)
if err == nil {
return id, status, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return "", "", err
}
}
toJSON, _ := json.Marshal(req.To)
ccJSON, _ := json.Marshal(req.Cc)
bccJSON, _ := json.Marshal(req.Bcc)
inReplyTo := threading.NormalizeMessageID(req.InReplyTo)
var references []string
if req.ReplyToMessageID != "" {
parent, err := s.loadReplyParent(ctx, userID, req.ReplyToMessageID)
if err != nil {
return "", "", err
}
inReplyTo = threading.NormalizeMessageID(parent.MessageID)
references = threading.BuildReferences(parent.MessageID, parent.References)
}
status = "queued"
if req.ScheduleAt != nil {
status = "scheduled"
}
err = s.db.QueryRow(ctx, `
INSERT INTO outbox (
user_id, account_id, to_addrs, cc_addrs, bcc_addrs, subject,
body_text, body_html, in_reply_to, references_header, status, scheduled_at, idempotency_key
)
SELECT $1, ma.id, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
FROM mail_accounts ma
WHERE ma.id = $2 AND ma.user_id = $1
RETURNING id
`, userID, req.AccountID, toJSON, ccJSON, bccJSON, req.Subject, req.BodyText, req.BodyHTML,
inReplyTo, references, status, req.ScheduleAt, req.IdempotencyKey).Scan(&id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", "", ErrAccountNotFound
}
if req.IdempotencyKey != "" && isUniqueViolation(err) {
err = s.db.QueryRow(ctx, `
SELECT id, status FROM outbox
WHERE user_id = $1 AND idempotency_key = $2
ORDER BY created_at DESC
LIMIT 1
`, userID, req.IdempotencyKey).Scan(&id, &status)
if err == nil {
return id, status, nil
}
}
return "", "", err
}
return id, status, nil
}
type RulesList struct {
Rules []map[string]any `json:"rules"`
Pagination query.PaginationMeta `json:"pagination,omitempty"`
}
func (s *Service) ListRules(ctx context.Context, externalID string, params query.ListParams) (RulesList, error) {
var total int64
err := s.db.QueryRow(ctx, `
SELECT COUNT(*) FROM mail_rules WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
`, externalID).Scan(&total)
if err != nil {
return RulesList{}, err
}
rows, err := s.db.Query(ctx, `
SELECT id, name, priority, conditions, actions, is_active, match_count, rule_kind, workflow
FROM mail_rules WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
ORDER BY priority ASC
LIMIT $2 OFFSET $3
`, externalID, params.Limit(), params.Offset())
if err != nil {
return RulesList{}, err
}
defer rows.Close()
rules := make([]map[string]any, 0)
for rows.Next() {
var id, name string
var priority int
var conditions, actions []byte
var workflow []byte
var ruleKind string
var isActive bool
var matchCount int64
if err := rows.Scan(&id, &name, &priority, &conditions, &actions, &isActive, &matchCount, &ruleKind, &workflow); err != nil {
return RulesList{}, err
}
rules = append(rules, map[string]any{
"id": id, "name": name, "priority": priority,
"conditions": json.RawMessage(conditions), "actions": json.RawMessage(actions),
"is_active": isActive, "match_count": matchCount,
"rule_kind": ruleKind, "workflow": json.RawMessage(workflow),
})
}
if err := rows.Err(); err != nil {
return RulesList{}, err
}
return RulesList{
Rules: rules,
Pagination: params.Meta(&total),
}, nil
}
func (s *Service) CreateRule(ctx context.Context, userID string, req *createRuleRequest) (string, error) {
condJSON, _ := json.Marshal(req.Conditions)
if req.Conditions == nil {
condJSON = []byte("[]")
}
actJSON, _ := json.Marshal(req.Actions)
if req.Actions == nil {
actJSON = []byte("[]")
}
wfJSON, _ := json.Marshal(req.Workflow)
ruleKind := req.RuleKind
if ruleKind == "" {
ruleKind = "rule"
}
if req.AccountID != "" {
var exists bool
err := s.db.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM mail_accounts WHERE id = $1 AND user_id = $2)
`, req.AccountID, userID).Scan(&exists)
if err != nil {
return "", err
}
if !exists {
return "", ErrAccountNotFound
}
}
var id string
err := s.db.QueryRow(ctx, `
INSERT INTO mail_rules (user_id, account_id, name, priority, conditions, actions, rule_kind, workflow)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id
`, userID, nilIfEmpty(req.AccountID), req.Name, req.Priority, condJSON, actJSON, ruleKind, wfJSON).Scan(&id)
if err != nil {
return "", err
}
return id, nil
}
func (s *Service) UpdateRule(ctx context.Context, externalID, ruleID string, req *updateRuleRequest) error {
condJSON, _ := json.Marshal(req.Conditions)
if req.Conditions == nil {
condJSON = []byte("[]")
}
actJSON, _ := json.Marshal(req.Actions)
if req.Actions == nil {
actJSON = []byte("[]")
}
wfJSON, _ := json.Marshal(req.Workflow)
ruleKind := req.RuleKind
if ruleKind == "" {
ruleKind = "rule"
}
result, err := s.db.Exec(ctx, `
UPDATE mail_rules SET name=$1, priority=$2, is_active=$3, conditions=$4, actions=$5, rule_kind=$6, workflow=$7, updated_at=NOW()
WHERE id=$8 AND user_id=(SELECT id FROM users WHERE external_id=$9)
`, req.Name, req.Priority, req.IsActive, condJSON, actJSON, ruleKind, wfJSON, ruleID, externalID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Service) DeleteRule(ctx context.Context, externalID, ruleID string) error {
result, err := s.db.Exec(ctx, `
DELETE FROM mail_rules
WHERE id = $1 AND user_id = (SELECT id FROM users WHERE external_id = $2)
`, ruleID, externalID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
if s.audit != nil {
s.audit.Log(ctx, externalID, securityaudit.ActionCriticalDeletion, map[string]any{
"target": "mail_rule", "rule_id": ruleID,
})
}
return nil
}
type WebhooksList struct {
Webhooks []map[string]any `json:"webhooks"`
Pagination query.PaginationMeta `json:"pagination,omitempty"`
}
func (s *Service) ListWebhooks(ctx context.Context, externalID string, params query.ListParams) (WebhooksList, error) {
var total int64
err := s.db.QueryRow(ctx, `
SELECT COUNT(*) FROM webhook_templates
WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
`, externalID).Scan(&total)
if err != nil {
return WebhooksList{}, err
}
rows, err := s.db.Query(ctx, `
SELECT id, name, url, method, version, is_active FROM webhook_templates
WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
ORDER BY created_at ASC
LIMIT $2 OFFSET $3
`, externalID, params.Limit(), params.Offset())
if err != nil {
return WebhooksList{}, err
}
defer rows.Close()
webhooks := make([]map[string]any, 0)
for rows.Next() {
var id, name, url, method string
var version int
var isActive bool
if err := rows.Scan(&id, &name, &url, &method, &version, &isActive); err != nil {
return WebhooksList{}, err
}
webhooks = append(webhooks, map[string]any{
"id": id, "name": name, "url": url, "method": method, "version": version, "is_active": isActive,
})
}
if err := rows.Err(); err != nil {
return WebhooksList{}, err
}
return WebhooksList{
Webhooks: webhooks,
Pagination: params.Meta(&total),
}, nil
}
func (s *Service) CreateWebhook(ctx context.Context, externalID string, req *createWebhookRequest, method string, maxRetries int) (string, error) {
headersJSON, _ := json.Marshal(req.Headers)
var id string
err := s.db.QueryRow(ctx, `
INSERT INTO webhook_templates (user_id, name, url, method, headers, body_template, version, signing_secret, max_retries)
VALUES ((SELECT id FROM users WHERE external_id = $1), $2, $3, $4, $5, $6, 1, $7, $8)
RETURNING id
`, externalID, req.Name, req.URL, method, headersJSON, req.BodyTemplate, req.SigningSecret, maxRetries).Scan(&id)
if err != nil {
return "", err
}
if _, err := s.db.Exec(ctx, `
INSERT INTO webhook_template_versions (template_id, version, method, headers, body_template)
VALUES ($1, 1, $2, $3, $4)
`, id, method, headersJSON, req.BodyTemplate); err != nil {
return "", err
}
return id, nil
}
func (s *Service) DeleteWebhook(ctx context.Context, externalID, webhookID string) error {
result, err := s.db.Exec(ctx, `
DELETE FROM webhook_templates
WHERE id = $1 AND user_id = (SELECT id FROM users WHERE external_id = $2)
`, webhookID, externalID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
if s.audit != nil {
s.audit.Log(ctx, externalID, securityaudit.ActionCriticalDeletion, map[string]any{
"target": "webhook_template", "webhook_id": webhookID,
})
}
return nil
}
func nilIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}