diff --git a/.gitignore b/.gitignore index b919f141..01fe7373 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,26 @@ conf/rspamd/local.d/redis.conf ssl-self-signed/cert.pem ssl-self-signed/key.pem core/billionmail* + +# Server-specific runtime configs +conf/askai/ +conf/chat/ +conf/supplier/ +conf/tokens_cache/ +conf/postfix/conf/ +conf/postfix/main.cf +conf/postfix/master.cf +conf/rspamd/local.d/worker-controller.inc +conf/dovecot/conf.d/10-ssl.conf +conf/dovecot/conf.d/20-pop3.conf +conf/dovecot/conf.d/90-quota.conf +conf/dovecot/dovecot.conf + +# Build/runtime artifacts +core/frontend/package-lock.json +core/frontend/.pnpm-store/ +core/frontend/node_modules/ +core/frontend/dist/ +core/public/dist/ +docker-compose.yml.bak* +docker-compose_*.yml diff --git a/bm.sh b/bm.sh old mode 100644 new mode 100755 index b538f287..e320fedc --- a/bm.sh +++ b/bm.sh @@ -891,6 +891,60 @@ MODIFY_SAFE_ENTRANCE() { fi } +# Rebuild Frontend only +REBUILD_FRONTEND() { + echo "Rebuilding BillionMail Frontend..." + echo -e "\033[33mThis will rebuild the frontend UI and restart the core container.\033[0m" + + # Check if frontend directory exists + if [ ! -d "./core/frontend" ]; then + echo -e "\033[31mError: Frontend directory not found at ./core/frontend\033[0m" + exit 1 + fi + + echo "Step 1/4: Building frontend with Node.js (this may take a few minutes)..." + + # Use Docker to build the frontend (no need for Node.js installed on host) + docker run --rm \ + -v "$(pwd)/core/frontend:/app" \ + -w /app \ + node:20-alpine \ + sh -c "npm install -g pnpm && pnpm install && pnpm run build" + + if [ $? -eq 0 ]; then + echo -e "\033[32m✓ Frontend build completed successfully\033[0m" + else + echo -e "\033[31m✗ Frontend build failed\033[0m" + exit 1 + fi + + echo "Step 2/4: Copying build files to public directory..." + if [ -d "./core/frontend/dist" ]; then + # Clean old build files first + rm -rf ./core/public/dist/* + # Copy new build + cp -r ./core/frontend/dist/* ./core/public/dist/ + echo -e "\033[32m✓ Build files copied to core/public/dist/\033[0m" + else + echo -e "\033[31m✗ Build directory not found\033[0m" + exit 1 + fi + + echo "Step 3/4: Restarting core container to load new frontend..." + SERVICE="core" + GET_SERVICE_NAME + if [ -n "${SERVICE_NAME}" ]; then + ${DOCKER_COMPOSE} restart ${SERVICE_NAME} + echo -e "\033[32m✓ Core container restarted\033[0m" + else + echo -e "\033[31m✗ Core service not found\033[0m" + exit 1 + fi + + echo "Step 4/4: Clearing browser cache recommended..." + echo -e "\033[32m✓ Frontend rebuild completed! Please refresh your browser (Ctrl+Shift+R or Cmd+Shift+R)\033[0m" +} + # Restart the BillionMail project RESTART_PROJECT() { echo "Restarting BillionMail..." @@ -1270,7 +1324,12 @@ APPLY_MULTI_IP() { else echo "📋 Found $(wc -l < "$TEMP_FILE") configuration records" + # Temporarily disable ERR trap for the loop to prevent early exit + trap - ERR + # Safely process query results + INSERTED_COUNT=0 + FAILED_COUNT=0 while IFS='|' read -r domain smtp_name; do # Clean whitespace and validate domain=$(echo "$domain" | xargs) @@ -1292,33 +1351,43 @@ APPLY_MULTI_IP() { # Use secure SQL query escaped_domain=$(printf '%s\n' "$domain_with_at" | sed "s/'/''/g") - EXISTS=$(docker exec -i -e PGPASSWORD=${DBPASS} ${PGSQL_CONTAINER_NAME} psql -U ${DBUSER} -d ${DBNAME} -t -A \ - -c "SELECT 1 FROM bm_domain_smtp_transport WHERE domain = '$escaped_domain';") + EXISTS=$(docker exec -e PGPASSWORD=${DBPASS} ${PGSQL_CONTAINER_NAME} psql -U ${DBUSER} -d ${DBNAME} -t -A \ + -c "SELECT 1 FROM bm_domain_smtp_transport WHERE domain = '$escaped_domain';" 2>/dev/null /dev/null /dev/null + + + + @@ -212,6 +216,7 @@ const form = reactive({ tag_logic: 'OR', track_click: 1, track_open: 1, + rotate_senders: 0, }) const logicOptions = [ @@ -376,6 +381,7 @@ const getParams = () => { return { track_open: form.track_open, track_click: form.track_click, + rotate_senders: form.rotate_senders, addresser: form.addresser || '', full_name: form.full_name, subject: form.subject, @@ -446,6 +452,7 @@ const initForm = async () => { form.tag_logic = res.tag_logic form.track_open = res.track_open form.track_click = res.track_click + form.rotate_senders = res.rotate_senders || 0 nextTick(() => { form.tag_ids = res.tag_ids }) diff --git a/core/internal/model/entity/batch_mail.go b/core/internal/model/entity/batch_mail.go index 3db96eaa..4e4594c0 100644 --- a/core/internal/model/entity/batch_mail.go +++ b/core/internal/model/entity/batch_mail.go @@ -90,6 +90,7 @@ type EmailTask struct { TagIds []int `json:"tag_ids" dc:"Tag IDs (parsed array)"` TagLogic string `json:"tag_logic" dc:"Tag Logic (AND/OR/NOT)"` UseTagFilter int `json:"use_tag_filter" dc:"Use Tag Filter (0: no, 1: yes)"` + RotateSenders int `json:"rotate_senders" dc:"Rotate through all domain mailboxes (0: no, 1: yes)"` } // MarshalJSON implements custom JSON marshaling to convert TagIdsRaw to TagIds array diff --git a/core/internal/service/batch_mail/api_mail_send.go b/core/internal/service/batch_mail/api_mail_send.go index eb2c94ac..1267709f 100644 --- a/core/internal/service/batch_mail/api_mail_send.go +++ b/core/internal/service/batch_mail/api_mail_send.go @@ -434,8 +434,12 @@ func sendApiMail(ctx context.Context, apiTemplate *entity.ApiTemplates, subject baseURL := domains.GetBaseURL() apiTemplate_id := apiTemplate.Id + 1000000000 mailTracker := maillog_stat.NewMailTracker(content, apiTemplate_id, messageId, log.Recipient, baseURL) - mailTracker.TrackLinks() - mailTracker.AppendTrackingPixel() + if apiTemplate.TrackClick == 1 { + mailTracker.TrackLinks() + } + if apiTemplate.TrackOpen == 1 { + mailTracker.AppendTrackingPixel() + } content = mailTracker.GetHTML() // create email message diff --git a/core/internal/service/batch_mail/batch_mail.go b/core/internal/service/batch_mail/batch_mail.go index b72dd11a..501bdf29 100644 --- a/core/internal/service/batch_mail/batch_mail.go +++ b/core/internal/service/batch_mail/batch_mail.go @@ -427,6 +427,7 @@ func CreateTaskWithRecipients(ctx context.Context, req *v1.CreateTaskReq, addTyp "threads": req.Threads, "track_open": req.TrackOpen, "track_click": req.TrackClick, + "rotate_senders": req.RotateSenders, "start_time": req.StartTime, "create_time": now, "update_time": now, diff --git a/core/internal/service/batch_mail/task_executor.go b/core/internal/service/batch_mail/task_executor.go index c558e537..ea6ce834 100644 --- a/core/internal/service/batch_mail/task_executor.go +++ b/core/internal/service/batch_mail/task_executor.go @@ -1185,7 +1185,27 @@ func (e *TaskExecutor) sendEmail(ctx context.Context, task *entity.EmailTask, re // get rendered content and subject renderedContent, renderedSubject := e.personalizeEmail(ctx, content, currentTask, recipient) - sender, err := mail_service.NewEmailSenderWithLocal(currentTask.Addresser) + // Determine sender email and name (with rotation if enabled) + senderEmail := currentTask.Addresser + senderName := currentTask.FullName + + if currentTask.RotateSenders == 1 { + // Get all mailboxes for the sender's domain + domain := extractDomain(currentTask.Addresser) + if domain != "" { + mailboxes, err := getMailboxesByDomain(ctx, domain) + if err != nil { + g.Log().Warning(ctx, "failed to get mailboxes for domain %s: %v, using original sender", domain, err) + } else if len(mailboxes) > 0 { + // Select mailbox based on recipient ID for consistent rotation + selected := selectRotatedSender(mailboxes, recipient.Id) + senderEmail = selected.Username + senderName = selected.FullName + } + } + } + + sender, err := mail_service.NewEmailSenderWithLocal(senderEmail) if err != nil { g.Log().Error(ctx, "create email sender failed: %v", err) return &SendResult{ @@ -1202,17 +1222,21 @@ func (e *TaskExecutor) sendEmail(ctx context.Context, task *entity.EmailTask, re //baseURL := domains.GetBaseURLBySender(currentTask.Addresser) baseURL := domains.GetBaseURL() mail_tracker := maillog_stat.NewMailTracker(renderedContent, currentTask.Id, messageID, recipient.Recipient, baseURL) - mail_tracker.TrackLinks() - mail_tracker.AppendTrackingPixel() + if currentTask.TrackClick == 1 { + mail_tracker.TrackLinks() + } + if currentTask.TrackOpen == 1 { + mail_tracker.AppendTrackingPixel() + } renderedContent = mail_tracker.GetHTML() // create email message with rendered subject message := mail_service.NewMessage(renderedSubject, renderedContent) message.SetMessageID(messageID) - // set sender display name - if currentTask.FullName != "" { - message.SetRealName(currentTask.FullName) + // set sender display name (use rotated name if rotation is enabled) + if senderName != "" { + message.SetRealName(senderName) } //g.Log().Infof(ctx, "sendEmail - final check before sending: sender=%s, display_name=%s, subject=%s, recipient=%s", @@ -1254,7 +1278,27 @@ func (e *TaskExecutor) sendEmailMock(ctx context.Context, task *entity.EmailTask // Get the rendered content and subject renderedContent, renderedSubject := e.personalizeEmail(ctx, content, task, recipient) - sender, err := mail_service.NewEmailSenderWithLocal(task.Addresser) + // Determine sender email and name (with rotation if enabled) + senderEmail := task.Addresser + senderName := task.FullName + + if task.RotateSenders == 1 { + // Get all mailboxes for the sender's domain + domain := extractDomain(task.Addresser) + if domain != "" { + mailboxes, err := getMailboxesByDomain(ctx, domain) + if err != nil { + g.Log().Warning(ctx, "failed to get mailboxes for domain %s: %v, using original sender", domain, err) + } else if len(mailboxes) > 0 { + // Select mailbox based on recipient ID for consistent rotation + selected := selectRotatedSender(mailboxes, recipient.Id) + senderEmail = selected.Username + senderName = selected.FullName + } + } + } + + sender, err := mail_service.NewEmailSenderWithLocal(senderEmail) if err != nil { g.Log().Error(ctx, "Failed to create email sender: %v", err) return &SendResult{ @@ -1283,9 +1327,9 @@ func (e *TaskExecutor) sendEmailMock(ctx context.Context, task *entity.EmailTask message := mail_service.NewMessage(renderedSubject, renderedContent) message.SetMessageID(messageID) - // Set sender display name - if task.FullName != "" { - message.SetRealName(task.FullName) + // Set sender display name (use rotated name if rotation is enabled) + if senderName != "" { + message.SetRealName(senderName) } // We will create a log entry and save it, instead of sending. @@ -1293,13 +1337,13 @@ func (e *TaskExecutor) sendEmailMock(ctx context.Context, task *entity.EmailTask postfixMessageID := strings.ToUpper("TEST_" + grand.S(11)) nowMillis := time.Now().UnixMilli() - // 1. Create MailSender record + // 1. Create MailSender record (use rotated sender if applicable) senderRecord := &maillog_stat.MailSender{ MailRecord: maillog_stat.MailRecord{ PostfixMessageID: postfixMessageID, LogTimeMillis: nowMillis, }, - Sender: task.Addresser, + Sender: senderEmail, Size: int64(len(renderedContent)), } _, err = g.DB().Model("mailstat_senders").InsertIgnore(senderRecord) @@ -1549,3 +1593,40 @@ func (e *TaskExecutor) UpdateTaskThreads(taskId int, threads int) error { return nil } + +// MailboxInfo represents a mailbox with email and display name +type MailboxInfo struct { + Username string `json:"username"` + FullName string `json:"full_name"` +} + +// getMailboxesByDomain returns all active mailboxes for a given domain +func getMailboxesByDomain(ctx context.Context, domain string) ([]MailboxInfo, error) { + var mailboxes []MailboxInfo + err := g.DB().Model("mailbox"). + Where("domain", domain). + Where("active", 1). + Order("username ASC"). + Scan(&mailboxes) + if err != nil { + return nil, err + } + return mailboxes, nil +} + +// extractDomain extracts the domain from an email address +func extractDomain(email string) string { + parts := strings.Split(email, "@") + if len(parts) == 2 { + return parts[1] + } + return "" +} + +// selectRotatedSender selects a sender from the mailbox list based on recipient index +func selectRotatedSender(mailboxes []MailboxInfo, recipientIndex int) MailboxInfo { + if len(mailboxes) == 0 { + return MailboxInfo{} + } + return mailboxes[recipientIndex%len(mailboxes)] +} diff --git a/docker-compose.yml b/docker-compose.yml index 61a6cc6e..80c3f37b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -139,8 +139,15 @@ services: billionmail-network: aliases: - postfix - - + ipv4_address: 172.66.1.100 + billionmail-net-20260108050752: + aliases: + - aliases-aioutreach-shop + ipv4_address: 172.66.2.100 + billionmail-net-20260111185450: + aliases: + - aliases-aiemail-shop + ipv4_address: 172.66.3.100 webmail-billionmail: image: roundcube/roundcubemail:1.6.11-fpm-alpine hostname: roundcube @@ -192,6 +199,7 @@ services: - ./logs:/opt/billionmail/logs - ./logs/core:/opt/billionmail/core/logs - ./core-data:/opt/billionmail/core/data + - ./core/public:/opt/billionmail/core/public - /var/run/docker.sock:/var/run/docker.sock:ro - ./vmail-data:/opt/billionmail/vmail-data environment: @@ -220,3 +228,18 @@ networks: config: - subnet: ${IPV4_NETWORK:-172.66.1}.0/24 + + billionmail-net-20260108050752: + driver: bridge + ipam: + config: + - gateway: 172.66.2.1 + subnet: 172.66.2.0/24 + driver: default + billionmail-net-20260111185450: + driver: bridge + ipam: + config: + - gateway: 172.66.3.1 + subnet: 172.66.3.0/24 + driver: default \ No newline at end of file