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