diff --git a/backend/cache/local.go b/backend/cache/local.go index 0968030..85d8d7e 100644 --- a/backend/cache/local.go +++ b/backend/cache/local.go @@ -51,10 +51,11 @@ var CampaignEventPriority = map[string]int{ data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT: 20, data.EVENT_CAMPAIGN_RECIPIENT_SCHEDULED: 10, // campaign events - data.EVENT_CAMPAIGN_CLOSED: 30, - data.EVENT_CAMPAIGN_ACTIVE: 20, - data.EVENT_CAMPAIGN_SELF_MANAGED: 20, - data.EVENT_CAMPAIGN_SCHEDULED: 10, + data.EVENT_CAMPAIGN_CLOSED: 30, + data.EVENT_CAMPAIGN_ACTIVE: 20, + data.EVENT_CAMPAIGN_SELF_MANAGED: 20, + data.EVENT_CAMPAIGN_SCHEDULED: 10, + data.EVENT_CAMPAIGN_PENDING_SCHEDULE: 5, } // IsMoreNotableCampaignRecipientEvent returns true if newEvent is more notable than currentEvent diff --git a/backend/data/events.go b/backend/data/events.go index b9e1e50..8bd443a 100644 --- a/backend/data/events.go +++ b/backend/data/events.go @@ -1,10 +1,11 @@ package data const ( - EVENT_CAMPAIGN_SCHEDULED = "campaign_scheduled" - EVENT_CAMPAIGN_ACTIVE = "campaign_active" - EVENT_CAMPAIGN_SELF_MANAGED = "campaign_self_managed" - EVENT_CAMPAIGN_CLOSED = "campaign_closed" + EVENT_CAMPAIGN_SCHEDULED = "campaign_scheduled" + EVENT_CAMPAIGN_ACTIVE = "campaign_active" + EVENT_CAMPAIGN_SELF_MANAGED = "campaign_self_managed" + EVENT_CAMPAIGN_PENDING_SCHEDULE = "campaign_pending_schedule" + EVENT_CAMPAIGN_CLOSED = "campaign_closed" EVENT_CAMPAIGN_RECIPIENT_SCHEDULED = "campaign_recipient_scheduled" EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT = "campaign_recipient_message_sent" @@ -26,6 +27,7 @@ var Events = []string{ EVENT_CAMPAIGN_SCHEDULED, EVENT_CAMPAIGN_ACTIVE, EVENT_CAMPAIGN_SELF_MANAGED, + EVENT_CAMPAIGN_PENDING_SCHEDULE, EVENT_CAMPAIGN_CLOSED, // campaign recipient events EVENT_CAMPAIGN_RECIPIENT_SCHEDULED, diff --git a/backend/database/campaign.go b/backend/database/campaign.go index 7b48f36..f99cf25 100644 --- a/backend/database/campaign.go +++ b/backend/database/campaign.go @@ -25,6 +25,15 @@ type Campaign struct { SortOrder string `gorm:";"` // 'asc,desc,random' SendStartAt *time.Time `gorm:"index;"` SendEndAt *time.Time `gorm:"index;"` + // ScheduleAt is set when the campaign uses late-scheduling. + // the task runner will call schedule() when now >= ScheduleAt. + // null means the campaign was scheduled immediately at creation. + ScheduleAt *time.Time `gorm:"index;"` + // JitterMin and JitterMax are persisted only when ScheduleAt is set (late-scheduling). + // They are cleared after the campaign is scheduled by the task runner. + // For immediately-scheduled campaigns these columns remain null. + JitterMin *int `gorm:""` + JitterMax *int `gorm:""` // ConstraintWeekDays is a binary format. // 0b00000001 = 1 = sunday diff --git a/backend/model/campaign.go b/backend/model/campaign.go index 43dd35d..2ab4774 100644 --- a/backend/model/campaign.go +++ b/backend/model/campaign.go @@ -28,11 +28,14 @@ type Campaign struct { SortOrder nullable.Nullable[vo.CampaignSendingOrder] `json:"sortOrder"` SendStartAt nullable.Nullable[time.Time] `json:"sendStartAt"` SendEndAt nullable.Nullable[time.Time] `json:"sendEndAt"` + ScheduleAt nullable.Nullable[time.Time] `json:"scheduleAt"` ConstraintWeekDays nullable.Nullable[vo.CampaignWeekDays] `json:"constraintWeekDays"` ConstraintStartTime nullable.Nullable[vo.CampaignTimeConstraint] `json:"constraintStartTime"` ConstraintEndTime nullable.Nullable[vo.CampaignTimeConstraint] `json:"constraintEndTime"` - // jitter is used only during scheduling, not persisted to database + // jitter is persisted to the database only when ScheduleAt is set (late-scheduling), + // so the task runner can apply it when schedule() is called hours later. + // For immediately-scheduled campaigns jitter lives in memory only and is never written to the DB. JitterMin nullable.Nullable[int] `json:"jitterMin,omitempty"` JitterMax nullable.Nullable[int] `json:"jitterMax,omitempty"` @@ -410,6 +413,12 @@ func (c *Campaign) ToDBMap() map[string]any { m["send_end_at"] = utils.RFC3339UTC(v) } } + if c.ScheduleAt.IsSpecified() { + m["schedule_at"] = nil + if v, err := c.ScheduleAt.Get(); err == nil { + m["schedule_at"] = utils.RFC3339UTC(v) + } + } if c.CloseAt.IsSpecified() { m["close_at"] = nil if v, err := c.CloseAt.Get(); err == nil { @@ -504,6 +513,18 @@ func (c *Campaign) ToDBMap() map[string]any { if v, err := c.NotableEventID.Get(); err == nil { m["notable_event_id"] = v.String() } + if c.JitterMin.IsSpecified() { + m["jitter_min"] = nil + if v, err := c.JitterMin.Get(); err == nil { + m["jitter_min"] = v + } + } + if c.JitterMax.IsSpecified() { + m["jitter_max"] = nil + if v, err := c.JitterMax.Get(); err == nil { + m["jitter_max"] = v + } + } return m } diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index d135375..db70813 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -553,6 +553,9 @@ func (m *ProxyHandler) prepareRequestWithoutSession(req *http.Request, reqCtx *R if hostConfig.Rewrite != nil { for _, replacement := range hostConfig.Rewrite { if replacement.From == "" || replacement.From == "request_body" || replacement.From == "any" { + if !m.rewriteRuleMatchesRequest(replacement, req) { + continue + } engine := replacement.Engine if engine == "" { engine = "regex" @@ -929,27 +932,48 @@ func (m *ProxyHandler) rewriteResponseHeadersWithContext(resp *http.Response, re // apply custom replacement rules for response headers (after all hardcoded changes) if reqCtx.Session != nil { varCtx := m.buildVariablesContext(resp.Request.Context(), reqCtx.Session, reqCtx.ProxyConfig) - m.applyCustomResponseHeaderReplacementsWithVariables(resp, reqCtx.Session, varCtx) + m.applyCustomResponseHeaderReplacementsWithVariables(resp, reqCtx.Session, reqCtx.ProxyConfig, varCtx) } } -func (m *ProxyHandler) applyCustomResponseHeaderReplacements(resp *http.Response, session *service.ProxySession) { - m.applyCustomResponseHeaderReplacementsWithVariables(resp, session, nil) +func (m *ProxyHandler) applyCustomResponseHeaderReplacements(resp *http.Response, session *service.ProxySession, proxyConfig *service.ProxyServiceConfigYAML) { + m.applyCustomResponseHeaderReplacementsWithVariables(resp, session, proxyConfig, nil) } -func (m *ProxyHandler) applyCustomResponseHeaderReplacementsWithVariables(resp *http.Response, session *service.ProxySession, varCtx *VariablesContext) { +func (m *ProxyHandler) applyCustomResponseHeaderReplacementsWithVariables(resp *http.Response, session *service.ProxySession, proxyConfig *service.ProxyServiceConfigYAML, varCtx *VariablesContext) { // get all headers as a string var buf bytes.Buffer resp.Header.Write(&buf) headers := buf.Bytes() - // only apply rewrite rules for the current host + // apply per-host rewrite rules for the current host if hostConfig, ok := session.Config.Load(resp.Request.Host); ok { hCfg := hostConfig.(service.ProxyServiceDomainConfig) if hCfg.Rewrite != nil { for _, replacement := range hCfg.Rewrite { if replacement.From == "response_header" || replacement.From == "any" { - headers = m.applyReplacementWithVariables(headers, replacement, session.ID, varCtx, "") + if m.rewriteRuleMatchesRequest(replacement, resp.Request) { + if replacement.Engine == "header" { + m.applyHeaderRewriteToHeaders(resp.Header, replacement, session.ID, varCtx) + } else { + headers = m.applyReplacementWithVariables(headers, replacement, session.ID, varCtx, "") + } + } + } + } + } + } + + // apply global rewrite rules for response headers + if proxyConfig != nil && proxyConfig.Global != nil && proxyConfig.Global.Rewrite != nil { + for _, replacement := range proxyConfig.Global.Rewrite { + if replacement.From == "response_header" || replacement.From == "any" { + if m.rewriteRuleMatchesRequest(replacement, resp.Request) { + if replacement.Engine == "header" { + m.applyHeaderRewriteToHeaders(resp.Header, replacement, session.ID, varCtx) + } else { + headers = m.applyReplacementWithVariables(headers, replacement, session.ID, varCtx, "") + } } } } @@ -1002,7 +1026,7 @@ func (m *ProxyHandler) rewriteResponseBodyWithContext(resp *http.Response, reqCt // build variables context for template interpolation varCtx := m.buildVariablesContext(resp.Request.Context(), reqCtx.Session, reqCtx.ProxyConfig) - body = m.applyCustomReplacementsWithVariables(body, reqCtx.Session, reqCtx.TargetDomain, reqCtx.ProxyConfig, varCtx, contentType) + body = m.applyCustomReplacementsWithVariables(body, reqCtx.Session, reqCtx.TargetDomain, reqCtx.ProxyConfig, varCtx, contentType, resp.Request) // apply obfuscation if enabled if reqCtx.Campaign != nil && strings.Contains(contentType, "text/html") { @@ -1035,6 +1059,7 @@ func (m *ProxyHandler) processResponseWithoutSessionContext(resp *http.Response, // apply basic response processing m.removeSecurityHeaders(resp) m.rewriteLocationHeaderWithoutSession(resp, config) + m.applyCustomResponseHeaderReplacementsWithoutSession(resp, config, reqCtx.TargetDomain, reqCtx.ProxyConfig) m.rewriteResponseBodyWithoutSessionContext(resp, reqCtx, config) return resp @@ -1142,7 +1167,7 @@ func (m *ProxyHandler) rewriteResponseBodyWithoutSessionContext(resp *http.Respo body = m.patchUrls(configMap, body, CONVERT_TO_PHISHING_URLS) body = m.applyURLPathRewritesWithoutSession(body, reqCtx) - body = m.applyCustomReplacementsWithoutSession(body, configMap, reqCtx.TargetDomain, reqCtx.ProxyConfig, contentType) + body = m.applyCustomReplacementsWithoutSession(body, configMap, reqCtx.TargetDomain, reqCtx.ProxyConfig, contentType, resp.Request) // apply obfuscation if enabled if reqCtx.Campaign != nil && strings.Contains(contentType, "text/html") { @@ -1616,7 +1641,12 @@ func (m *ProxyHandler) matchesPath(capture service.ProxyServiceCaptureRule, req if capture.PathRe == nil { return true } - return capture.PathRe.MatchString(req.URL.Path) + // normalize empty path to "/" so rules like path: / match root requests + path := req.URL.Path + if path == "" { + path = "/" + } + return capture.PathRe.MatchString(path) } func (m *ProxyHandler) handlePathBasedCapture(capture service.ProxyServiceCaptureRule, session *service.ProxySession, resp *http.Response) { @@ -1638,7 +1668,11 @@ func (m *ProxyHandler) handlePathBasedCapture(capture service.ProxyServiceCaptur webhookData := map[string]interface{}{ capture.Name: capturedData, } - m.createCampaignSubmitEvent(session, webhookData, resp.Request, session.UserAgent) + if capture.Event == "info" { + m.createCampaignInfoEvent(session, webhookData, resp.Request, session.UserAgent) + } else { + m.createCampaignSubmitEvent(session, webhookData, resp.Request, session.UserAgent) + } } // check if cookie bundle should be submitted now that this capture is complete @@ -1820,7 +1854,11 @@ func (m *ProxyHandler) captureFromTextWithResponse(text string, capture service. webhookData := map[string]interface{}{ capture.Name: capturedData, } - m.createCampaignSubmitEvent(session, webhookData, req, session.UserAgent) + if capture.Event == "info" { + m.createCampaignInfoEvent(session, webhookData, req, session.UserAgent) + } else { + m.createCampaignSubmitEvent(session, webhookData, req, session.UserAgent) + } } // check if we should submit cookie bundle (only when all captures complete) @@ -2342,7 +2380,9 @@ func (m *ProxyHandler) applyRequestBodyReplacementsWithVariables(req *http.Reque if hCfg.Rewrite != nil { for _, replacement := range hCfg.Rewrite { if replacement.From == "" || replacement.From == "request_body" || replacement.From == "any" { - body = m.applyReplacementWithVariables(body, replacement, session.ID, varCtx, "") + if m.rewriteRuleMatchesRequest(replacement, req) { + body = m.applyReplacementWithVariables(body, replacement, session.ID, varCtx, "") + } } } } @@ -2352,7 +2392,9 @@ func (m *ProxyHandler) applyRequestBodyReplacementsWithVariables(req *http.Reque if proxyConfig != nil && proxyConfig.Global != nil && proxyConfig.Global.Rewrite != nil { for _, replacement := range proxyConfig.Global.Rewrite { if replacement.From == "" || replacement.From == "request_body" || replacement.From == "any" { - body = m.applyReplacementWithVariables(body, replacement, session.ID, varCtx, "") + if m.rewriteRuleMatchesRequest(replacement, req) { + body = m.applyReplacementWithVariables(body, replacement, session.ID, varCtx, "") + } } } } @@ -2360,18 +2402,20 @@ func (m *ProxyHandler) applyRequestBodyReplacementsWithVariables(req *http.Reque req.Body = io.NopCloser(bytes.NewBuffer(body)) } -func (m *ProxyHandler) applyCustomReplacements(body []byte, session *service.ProxySession, targetDomain string, proxyConfig *service.ProxyServiceConfigYAML) []byte { - return m.applyCustomReplacementsWithVariables(body, session, targetDomain, proxyConfig, nil, "") +func (m *ProxyHandler) applyCustomReplacements(body []byte, session *service.ProxySession, targetDomain string, proxyConfig *service.ProxyServiceConfigYAML, req *http.Request) []byte { + return m.applyCustomReplacementsWithVariables(body, session, targetDomain, proxyConfig, nil, "", req) } -func (m *ProxyHandler) applyCustomReplacementsWithVariables(body []byte, session *service.ProxySession, targetDomain string, proxyConfig *service.ProxyServiceConfigYAML, varCtx *VariablesContext, contentType string) []byte { +func (m *ProxyHandler) applyCustomReplacementsWithVariables(body []byte, session *service.ProxySession, targetDomain string, proxyConfig *service.ProxyServiceConfigYAML, varCtx *VariablesContext, contentType string, req *http.Request) []byte { // apply rewrite rules for the current request's target domain if hostConfig, ok := session.Config.Load(targetDomain); ok { hCfg := hostConfig.(service.ProxyServiceDomainConfig) if hCfg.Rewrite != nil { for _, replacement := range hCfg.Rewrite { if replacement.From == "" || replacement.From == "response_body" || replacement.From == "any" { - body = m.applyReplacementWithVariables(body, replacement, session.ID, varCtx, contentType) + if m.rewriteRuleMatchesRequest(replacement, req) { + body = m.applyReplacementWithVariables(body, replacement, session.ID, varCtx, contentType) + } } } } @@ -2381,7 +2425,9 @@ func (m *ProxyHandler) applyCustomReplacementsWithVariables(body []byte, session if proxyConfig != nil && proxyConfig.Global != nil && proxyConfig.Global.Rewrite != nil { for _, replacement := range proxyConfig.Global.Rewrite { if replacement.From == "" || replacement.From == "response_body" || replacement.From == "any" { - body = m.applyReplacementWithVariables(body, replacement, session.ID, varCtx, contentType) + if m.rewriteRuleMatchesRequest(replacement, req) { + body = m.applyReplacementWithVariables(body, replacement, session.ID, varCtx, contentType) + } } } } @@ -2390,13 +2436,73 @@ func (m *ProxyHandler) applyCustomReplacementsWithVariables(body []byte, session } // applyCustomReplacementsWithoutSession applies rewrite rules for requests without session context -func (m *ProxyHandler) applyCustomReplacementsWithoutSession(body []byte, config map[string]service.ProxyServiceDomainConfig, targetDomain string, proxyConfig *service.ProxyServiceConfigYAML, contentType string) []byte { +// applyCustomResponseHeaderReplacementsWithoutSession applies response_header rewrite rules for requests without a session +func (m *ProxyHandler) applyCustomResponseHeaderReplacementsWithoutSession(resp *http.Response, config map[string]service.ProxyServiceDomainConfig, targetDomain string, proxyConfig *service.ProxyServiceConfigYAML) { + var buf bytes.Buffer + resp.Header.Write(&buf) + headers := buf.Bytes() + + // apply per-host rewrite rules + if hostConfig, ok := config[targetDomain]; ok { + if hostConfig.Rewrite != nil { + for _, replacement := range hostConfig.Rewrite { + if replacement.From == "response_header" || replacement.From == "any" { + if m.rewriteRuleMatchesRequest(replacement, resp.Request) { + if replacement.Engine == "header" { + m.applyHeaderRewriteToHeaders(resp.Header, replacement, "no-session", nil) + } else { + headers = m.applyReplacement(headers, replacement, "no-session", "") + } + } + } + } + } + } + + // apply global rewrite rules + if proxyConfig != nil && proxyConfig.Global != nil && proxyConfig.Global.Rewrite != nil { + for _, replacement := range proxyConfig.Global.Rewrite { + if replacement.From == "response_header" || replacement.From == "any" { + if m.rewriteRuleMatchesRequest(replacement, resp.Request) { + if replacement.Engine == "header" { + m.applyHeaderRewriteToHeaders(resp.Header, replacement, "no-session", nil) + } else { + headers = m.applyReplacement(headers, replacement, "no-session", "") + } + } + } + } + } + + // parse the modified headers back + if string(headers) != buf.String() { + resp.Header = make(http.Header) + lines := strings.Split(string(headers), "\r\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if parts := strings.SplitN(line, ":", 2); len(parts) == 2 { + headerName := strings.TrimSpace(parts[0]) + headerValue := strings.TrimSpace(parts[1]) + if headerName != "" { + resp.Header.Add(headerName, headerValue) + } + } + } + } +} + +func (m *ProxyHandler) applyCustomReplacementsWithoutSession(body []byte, config map[string]service.ProxyServiceDomainConfig, targetDomain string, proxyConfig *service.ProxyServiceConfigYAML, contentType string, req *http.Request) []byte { // apply rewrite rules for the current target domain if hostConfig, ok := config[targetDomain]; ok { if hostConfig.Rewrite != nil { for _, replacement := range hostConfig.Rewrite { if replacement.From == "" || replacement.From == "response_body" || replacement.From == "any" { - body = m.applyReplacement(body, replacement, "no-session", contentType) + if m.rewriteRuleMatchesRequest(replacement, req) { + body = m.applyReplacement(body, replacement, "no-session", contentType) + } } } } @@ -2406,7 +2512,9 @@ func (m *ProxyHandler) applyCustomReplacementsWithoutSession(body []byte, config if proxyConfig != nil && proxyConfig.Global != nil && proxyConfig.Global.Rewrite != nil { for _, replacement := range proxyConfig.Global.Rewrite { if replacement.From == "" || replacement.From == "response_body" || replacement.From == "any" { - body = m.applyReplacement(body, replacement, "no-session", contentType) + if m.rewriteRuleMatchesRequest(replacement, req) { + body = m.applyReplacement(body, replacement, "no-session", contentType) + } } } } @@ -2414,10 +2522,72 @@ func (m *ProxyHandler) applyCustomReplacementsWithoutSession(body []byte, config return body } +// rewriteRuleMatchesRequest returns true if the rewrite rule's path/method constraints +// match the given request. If no constraints are set the rule always matches. +// If req is nil (no request context available), rules with path/method constraints are skipped. +func (m *ProxyHandler) rewriteRuleMatchesRequest(rule service.ProxyServiceReplaceRule, req *http.Request) bool { + if req == nil { + return rule.Path == "" && rule.Method == "" + } + if rule.Method != "" && !strings.EqualFold(rule.Method, req.Method) { + return false + } + if rule.Path != "" { + if rule.PathRe == nil { + // path was set but failed to compile — skip rule safely + return false + } + if !rule.PathRe.MatchString(req.URL.Path) { + return false + } + } + return true +} + func (m *ProxyHandler) applyReplacement(body []byte, replacement service.ProxyServiceReplaceRule, sessionID string, contentType string) []byte { return m.applyReplacementWithVariables(body, replacement, sessionID, nil, contentType) } +// applyHeaderRewriteToHeaders applies a header-engine rewrite rule directly to an http.Header map. +// find = header name (canonical form), action = "set" | "add" | "remove", replace = new value. +// This operates directly on the header map so no serialize/parse round-trip is needed. +func (m *ProxyHandler) applyHeaderRewriteToHeaders(headers http.Header, rule service.ProxyServiceReplaceRule, sessionID string, varCtx *VariablesContext) { + if rule.Find == "" { + m.logger.Errorw("header rewrite rule has empty find (header name)", "sessionID", sessionID, "rule", rule.Name) + return + } + + // canonicalize the header name the same way Go's net/http does + canonicalName := http.CanonicalHeaderKey(rule.Find) + + action := rule.Action + if action == "" { + action = "set" + } + + switch action { + case "remove": + headers.Del(canonicalName) + + case "add": + value := rule.Replace + if varCtx != nil && varCtx.Enabled && varCtx.Data != nil { + value = m.interpolateVariables(value, varCtx) + } + headers.Add(canonicalName, value) + + case "set": + value := rule.Replace + if varCtx != nil && varCtx.Enabled && varCtx.Data != nil { + value = m.interpolateVariables(value, varCtx) + } + headers.Set(canonicalName, value) + + default: + m.logger.Errorw("unsupported header rewrite action", "action", action, "sessionID", sessionID, "rule", rule.Name) + } +} + // applyReplacementWithVariables applies replacement with optional template variable interpolation func (m *ProxyHandler) applyReplacementWithVariables(body []byte, replacement service.ProxyServiceReplaceRule, sessionID string, varCtx *VariablesContext, contentType string) []byte { // interpolate variables in the replacement value if enabled @@ -2441,6 +2611,13 @@ func (m *ProxyHandler) applyReplacementWithVariables(body []byte, replacement se return m.applyRegexReplacement(body, interpolatedReplacement, sessionID) case "dom": return m.applyDomReplacement(body, interpolatedReplacement, sessionID, contentType) + case "header": + // header engine operates directly on http.Header — it cannot be used in a body rewrite context. + // callers that handle headers (applyCustomResponseHeaderReplacementsWithVariables, + // applyCustomResponseHeaderReplacementsWithoutSession, applyEarlyRequestHeaderReplacements) + // intercept this engine before reaching here. + m.logger.Errorw("header engine used in a non-header rewrite context — use from: request_header or response_header", "sessionID", sessionID, "rule", replacement.Name) + return body default: m.logger.Errorw("unsupported replacement engine", "engine", engine, "sessionID", sessionID) return body @@ -2842,10 +3019,17 @@ func (m *ProxyHandler) applyEarlyRequestHeaderReplacements(req *http.Request, re applyReplacements := func(replacements []service.ProxyServiceReplaceRule) { for _, replacement := range replacements { if replacement.From == "" || replacement.From == "request_header" || replacement.From == "any" { + if !m.rewriteRuleMatchesRequest(replacement, req) { + continue + } engine := replacement.Engine if engine == "" { engine = "regex" } + if engine == "header" { + m.applyHeaderRewriteToHeaders(req.Header, replacement, "early-request", nil) + continue + } if engine == "regex" { re, err := regexp.Compile(replacement.Find) if err != nil { @@ -3389,6 +3573,79 @@ func (m *ProxyHandler) buildCampaignFlowRedirectURL(session *service.ProxySessio return targetURL } +// createCampaignInfoEvent saves captured data as a low-priority info event instead of a submit event. +// The capture still participates in completion tracking and flow progression normally — only the +// saved event type differs. +func (m *ProxyHandler) createCampaignInfoEvent(session *service.ProxySession, capturedData map[string]interface{}, req *http.Request, originalUserAgent string) { + if session.CampaignID == nil || session.CampaignRecipientID == nil { + return + } + + ctx := context.Background() + + campaign := session.Campaign + if campaign == nil { + var err error + campaign, err = m.CampaignRepository.GetByID(ctx, session.CampaignID, &repository.CampaignOption{}) + if err != nil { + m.logger.Errorw("failed to get campaign for proxy info event", "error", err) + return + } + } + + var dataJSON []byte + var err error + if campaign.SaveSubmittedData.MustGet() { + dataJSON, err = json.Marshal(capturedData) + if err != nil { + m.logger.Errorw("failed to marshal captured data for info event", "error", err) + return + } + } else { + dataJSON = []byte("{}") + } + + infoEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_INFO] + if infoEventID == nil { + m.logger.Errorw("info event type not found in cache", "sessionID", session.ID) + return + } + + eventID := uuid.New() + clientIP := utils.ExtractClientIP(req) + metadata := model.ExtractCampaignEventMetadataFromHTTPRequest(req, campaign) + + event := &model.CampaignEvent{ + ID: &eventID, + CampaignID: session.CampaignID, + RecipientID: session.RecipientID, + EventID: infoEventID, + Data: vo.NewOptionalString1MBMust(string(dataJSON)), + Metadata: metadata, + IP: vo.NewOptionalString64Must(clientIP), + UserAgent: vo.NewOptionalString255Must(originalUserAgent), + } + + err = m.CampaignRepository.SaveEvent(ctx, event) + if err != nil { + m.logger.Errorw("failed to create campaign info event", "error", err) + } + + err = m.CampaignService.HandleWebhooks( + ctx, + session.CampaignID, + session.RecipientID, + data.EVENT_CAMPAIGN_RECIPIENT_INFO, + capturedData, + ) + if err != nil { + m.logger.Errorw("failed to handle webhooks for MITM proxy info event", + "error", err, + "campaignRecipientID", session.CampaignRecipientID.String(), + ) + } +} + func (m *ProxyHandler) createCampaignSubmitEvent(session *service.ProxySession, capturedData map[string]interface{}, req *http.Request, originalUserAgent string) { if session.CampaignID == nil || session.CampaignRecipientID == nil { return diff --git a/backend/repository/campaign.go b/backend/repository/campaign.go index c08b9e6..2e893d7 100644 --- a/backend/repository/campaign.go +++ b/backend/repository/campaign.go @@ -1366,6 +1366,41 @@ func (r *Campaign) GetReadyToAnonymize( return result, nil } +// GetReadyToLateSchedule returns campaigns where schedule_at <= now and +// the campaign has not yet been scheduled (notable event is still pending_schedule). +func (r *Campaign) GetReadyToLateSchedule( + ctx context.Context, + options *CampaignOption, +) (*model.Result[model.Campaign], error) { + result := model.NewEmptyResult[model.Campaign]() + db := r.load(r.DB, options) + db, err := useQuery(db, database.CAMPAIGN_TABLE, options.QueryArgs) + if err != nil { + return result, errs.Wrap(err) + } + pendingEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_PENDING_SCHEDULE] + var dbCampaigns []database.Campaign + res := db. + Where("schedule_at <= ? AND notable_event_id = ?", utils.NowRFC3339UTC(), pendingEventID). + Find(&dbCampaigns) + if res.Error != nil { + return result, res.Error + } + hasNextPage, err := useHasNextPage(db, database.CAMPAIGN_TABLE, options.QueryArgs) + if err != nil { + return result, errs.Wrap(err) + } + result.HasNextPage = hasNextPage + for _, dbCampaign := range dbCampaigns { + campaign, err := ToCampaign(&dbCampaign) + if err != nil { + return nil, errs.Wrap(err) + } + result.Rows = append(result.Rows, campaign) + } + return result, nil +} + // SaveEvent saves a campaign event func (r *Campaign) SaveEvent( ctx context.Context, @@ -1793,6 +1828,24 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) { } else { sendEndAt.SetNull() } + var scheduleAt nullable.Nullable[time.Time] + if row.ScheduleAt != nil { + scheduleAt = nullable.NewNullableWithValue(*row.ScheduleAt) + } else { + scheduleAt.SetNull() + } + var jitterMin nullable.Nullable[int] + if row.JitterMin != nil { + jitterMin = nullable.NewNullableWithValue(*row.JitterMin) + } else { + jitterMin.SetNull() + } + var jitterMax nullable.Nullable[int] + if row.JitterMax != nil { + jitterMax = nullable.NewNullableWithValue(*row.JitterMax) + } else { + jitterMax.SetNull() + } saveSubmittedData := nullable.NewNullableWithValue(row.SaveSubmittedData) saveBrowserMetadata := nullable.NewNullableWithValue(row.SaveBrowserMetadata) isAnonymous := nullable.NewNullableWithValue(row.IsAnonymous) @@ -1927,6 +1980,9 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) { SortOrder: sortOrder, SendStartAt: sendStartAt, SendEndAt: sendEndAt, + ScheduleAt: scheduleAt, + JitterMin: jitterMin, + JitterMax: jitterMax, ConstraintWeekDays: constraintWeekDays, ConstraintStartTime: constraintStartTime, ConstraintEndTime: constraintEndTime, diff --git a/backend/repository/recipient.go b/backend/repository/recipient.go index 631f7e1..02e494f 100644 --- a/backend/repository/recipient.go +++ b/backend/repository/recipient.go @@ -437,6 +437,7 @@ func (r *Recipient) GetOrphaned( database.RECIPIENT_TABLE, database.RECIPIENT_TABLE, database.RECIPIENT_TABLE, + database.RECIPIENT_TABLE, ) query := fmt.Sprintf( diff --git a/backend/service/campaign.go b/backend/service/campaign.go index 6a71b05..5ec6e1b 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -118,6 +118,33 @@ func (c *Campaign) Create( return nil, errs.Wrap(err) } } + // late-schedule (indicated by a non-null schedule_at) is not compatible with self-managed campaigns + if campaign.ScheduleAt.IsSpecified() && !campaign.ScheduleAt.IsNull() { + if campaign.IsSelfManaged() { + return nil, validate.WrapErrorWithField( + errors.New("late scheduling is not available for self-managed campaigns"), + "scheduleAt", + ) + } + // scheduleAt must be in the future + scheduleAt := campaign.ScheduleAt.MustGet() + if !scheduleAt.After(time.Now().UTC()) { + return nil, validate.WrapErrorWithField( + errors.New("schedule time must be in the future"), + "scheduleAt", + ) + } + // send_start_at must be more than 24h after schedule_at + if campaign.SendStartAt.IsSpecified() && !campaign.SendStartAt.IsNull() { + sendStartAt := campaign.SendStartAt.MustGet() + if sendStartAt.Sub(scheduleAt) < 24*time.Hour { + return nil, validate.WrapErrorWithField( + errors.New("send start must be at least 24 hours after the schedule-at time"), + "scheduleAt", + ) + } + } + } // validate if err := campaign.Validate(); err != nil { return nil, errs.Wrap(err) @@ -213,14 +240,26 @@ func (c *Campaign) Create( c.Logger.Errorw("failed to get campaign by id", "error", err) return nil, errs.Wrap(err) } - // preserve jitter values from original campaign (not persisted to db) - createdCampaign.JitterMin = campaign.JitterMin - createdCampaign.JitterMax = campaign.JitterMax - err = c.schedule(ctx, session, createdCampaign) - if err != nil { - c.Logger.Errorw("failed to schedule campaign", "error", err) - // TODO we should delete the campaign as it was not scheduled - return nil, errs.Wrap(err) + if createdCampaign.ScheduleAt.IsSpecified() && !createdCampaign.ScheduleAt.IsNull() { + // Late-scheduling: persist jitter to the DB so the task runner can apply it + // when schedule() is called hours later. The in-memory copy on createdCampaign + // is set too so setMostNotableCampaignEvent's UpdateByID writes the columns. + createdCampaign.JitterMin = campaign.JitterMin + createdCampaign.JitterMax = campaign.JitterMax + err = c.setMostNotableCampaignEvent(ctx, createdCampaign, data.EVENT_CAMPAIGN_PENDING_SCHEDULE) + if err != nil { + return nil, errs.Wrap(err) + } + } else { + // Immediate scheduling: jitter lives in memory only for this call, never written to DB. + createdCampaign.JitterMin = campaign.JitterMin + createdCampaign.JitterMax = campaign.JitterMax + err = c.schedule(ctx, session, createdCampaign) + if err != nil { + c.Logger.Errorw("failed to schedule campaign", "error", err) + // TODO we should delete the campaign as it was not scheduled + return nil, errs.Wrap(err) + } } ae.Details["id"] = id.String() c.AuditLogAuthorized(ae) @@ -1482,6 +1521,15 @@ func (c *Campaign) UpdateByID( if v, err := incoming.AnonymizedAt.Get(); err == nil { current.AnonymizedAt.Set(v.Truncate(time.Minute)) } + if v, err := incoming.ScheduleAt.Get(); err == nil { + current.ScheduleAt.Set(v) + } else if incoming.ScheduleAt.IsSpecified() { + // incoming was explicitly null — clear the scheduled time, reverting to immediate scheduling + current.ScheduleAt.SetNull() + // also clear any persisted jitter — it was stored for late-scheduling and is no longer needed + current.JitterMin.SetNull() + current.JitterMax.SetNull() + } if v, err := incoming.RecipientGroupIDs.Get(); err == nil { current.RecipientGroupIDs.Set(v) } @@ -1565,6 +1613,47 @@ func (c *Campaign) UpdateByID( current.EvasionPageID.Set(incoming.EvasionPageID.MustGet()) } } + // late-schedule (indicated by a non-null schedule_at) is not compatible with self-managed campaigns + if current.ScheduleAt.IsSpecified() && !current.ScheduleAt.IsNull() { + if current.IsSelfManaged() { + return validate.WrapErrorWithField( + errors.New("late scheduling is not available for self-managed campaigns"), + "scheduleAt", + ) + } + // reject scheduleAt if the campaign has already moved past pending_schedule — + // at that point recipients have been resolved and scheduling is done; there is + // nothing meaningful for a new scheduleAt to do and it would revert the campaign + // back to pending_schedule state unexpectedly. + if currentNotableEventID, err := current.NotableEventID.Get(); err == nil { + if currentEventName, ok := cache.EventNameByID[currentNotableEventID.String()]; ok { + if cache.IsMoreNotableCampaignRecipientEvent(currentEventName, data.EVENT_CAMPAIGN_PENDING_SCHEDULE) { + return validate.WrapErrorWithField( + errors.New("scheduleAt cannot be set on a campaign that has already been scheduled"), + "scheduleAt", + ) + } + } + } + // scheduleAt must be in the future + scheduleAt := current.ScheduleAt.MustGet() + if !scheduleAt.After(time.Now().UTC()) { + return validate.WrapErrorWithField( + errors.New("schedule time must be in the future"), + "scheduleAt", + ) + } + // send_start_at must be more than 24h after schedule_at + if current.SendStartAt.IsSpecified() && !current.SendStartAt.IsNull() { + sendStartAt := current.SendStartAt.MustGet() + if sendStartAt.Sub(scheduleAt) < 24*time.Hour { + return validate.WrapErrorWithField( + errors.New("send start must be at least 24 hours after the schedule-at time"), + "scheduleAt", + ) + } + } + } // validate and update if err := current.Validate(); err != nil { return errs.Wrap(err) @@ -1638,13 +1727,27 @@ func (c *Campaign) UpdateByID( c.Logger.Errorw("failed to add recipient groups", "error", err) return errs.Wrap(err) } - // preserve jitter values from incoming campaign (not persisted to db) - current.JitterMin = incoming.JitterMin - current.JitterMax = incoming.JitterMax - err = c.schedule(ctx, session, current) - if err != nil { - c.Logger.Errorw("failed to re-schedule campaign", "error", err) - return errs.Wrap(err) + if current.ScheduleAt.IsSpecified() && !current.ScheduleAt.IsNull() { + // Late-scheduling: persist jitter to the DB so the task runner can apply it + // when schedule() is called hours later. Only overwrite jitter when the incoming + // payload explicitly specifies it — unspecified means "leave existing value alone". + if incoming.JitterMin.IsSpecified() { + current.JitterMin = incoming.JitterMin + current.JitterMax = incoming.JitterMax + } + err = c.setMostNotableCampaignEvent(ctx, current, data.EVENT_CAMPAIGN_PENDING_SCHEDULE) + if err != nil { + return errs.Wrap(err) + } + } else { + // Immediate scheduling: jitter lives in memory only for this call, never written to DB. + current.JitterMin = incoming.JitterMin + current.JitterMax = incoming.JitterMax + err = c.schedule(ctx, session, current) + if err != nil { + c.Logger.Errorw("failed to re-schedule campaign", "error", err) + return errs.Wrap(err) + } } c.AuditLogAuthorized(ae) return nil @@ -2906,6 +3009,65 @@ func (c *Campaign) HandleAnonymizeCampaigns( return nil } +// SchedulePendingCampaigns is called by the task runner. It finds all campaigns +// whose schedule_at time has passed and triggers schedule() for each. +func (c *Campaign) SchedulePendingCampaigns( + ctx context.Context, + session *model.Session, +) error { + ae := NewAuditEvent("Campaign.SchedulePendingCampaigns", session) + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + c.LogAuthError(err) + return errs.Wrap(err) + } + if !isAuthorized { + c.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + pending, err := c.CampaignRepository.GetReadyToLateSchedule( + ctx, + &repository.CampaignOption{ + WithRecipientGroups: true, + WithAllowDeny: true, + }, + ) + if err != nil { + c.Logger.Errorw("failed to get campaigns ready for late scheduling", "error", err) + return errs.Wrap(err) + } + for _, campaign := range pending.Rows { + campaignID := campaign.ID.MustGet() + // Clear schedule_at BEFORE calling schedule() so that a concurrent task runner tick + // or a second server instance cannot pick up the same campaign and double-schedule it. + // If schedule() subsequently fails the campaign will remain in pending_schedule state + // with a null schedule_at; an operator can re-set schedule_at to retry. + clearScheduleAt := model.Campaign{} + clearScheduleAt.ScheduleAt.SetNull() + if err := c.CampaignRepository.UpdateByID(ctx, &campaignID, &clearScheduleAt); err != nil { + c.Logger.Errorw("failed to clear schedule_at before late-scheduling, skipping campaign", "campaignID", campaignID, "error", err) + // skip this campaign — better to retry next tick than to risk a double-schedule + continue + } + // Jitter is loaded from the DB columns (persisted at creation/update time). + if err := c.schedule(ctx, session, campaign); err != nil { + c.Logger.Errorw("failed to late-schedule campaign", "campaignID", campaignID, "error", err) + // continue to next — don't abort the whole run + continue + } + // Clear persisted jitter now that scheduling is done — it is no longer needed. + clearJitter := model.Campaign{} + clearJitter.JitterMin.SetNull() + clearJitter.JitterMax.SetNull() + if err := c.CampaignRepository.UpdateByID(ctx, &campaignID, &clearJitter); err != nil { + c.Logger.Errorw("failed to clear jitter after late-scheduling", "campaignID", campaignID, "error", err) + // non-fatal — jitter columns being non-null is harmless after scheduling + } + c.Logger.Infow("late-scheduled campaign", "campaignID", campaignID) + } + return nil +} + // CloseCampaignByID closes a campaign by id // DeleteDeviceCodesByCampaignID deletes all device codes for a campaign so every recipient // gets a fresh code (and picks up any proxy change) on their next page visit. diff --git a/backend/service/proxy.go b/backend/service/proxy.go index d7fb168..7497672 100644 --- a/backend/service/proxy.go +++ b/backend/service/proxy.go @@ -160,7 +160,7 @@ func GetValidProxyVariableNames() []string { // - "public": Allow all traffic (traditional proxy mode) - on_deny is ignored // - "private": Strict IP-based mode like evilginx2 - whitelist IP after lure access, deny all others (DEFAULT) -// CompilePathPatterns compiles regex patterns for all capture and response rules +// CompilePathPatterns compiles regex patterns for all capture, response, and rewrite rules func CompilePathPatterns(config *ProxyServiceConfigYAML) error { // Compile global capture rule patterns if config.Global != nil && config.Global.Capture != nil { @@ -180,6 +180,15 @@ func CompilePathPatterns(config *ProxyServiceConfigYAML) error { } } + // Compile global rewrite rule patterns + if config.Global != nil && config.Global.Rewrite != nil { + for i := range config.Global.Rewrite { + if err := compileRewritePath(&config.Global.Rewrite[i]); err != nil { + return err + } + } + } + // Compile host-specific capture rule patterns for _, hostConfig := range config.Hosts { if hostConfig != nil && hostConfig.Capture != nil { @@ -201,6 +210,30 @@ func CompilePathPatterns(config *ProxyServiceConfigYAML) error { } } } + + // Compile host-specific rewrite rule patterns + for _, hostConfig := range config.Hosts { + if hostConfig != nil && hostConfig.Rewrite != nil { + for i := range hostConfig.Rewrite { + if err := compileRewritePath(&hostConfig.Rewrite[i]); err != nil { + return err + } + } + } + } + + return nil +} + +// compileRewritePath compiles the path pattern for a rewrite rule +func compileRewritePath(rule *ProxyServiceReplaceRule) error { + if rule.Path != "" { + pathRe, err := regexp.Compile(rule.Path) + if err != nil { + return fmt.Errorf("invalid regex pattern for rewrite path '%s': %w", rule.Path, err) + } + rule.PathRe = pathRe + } return nil } @@ -237,7 +270,8 @@ type ProxyServiceCaptureRule struct { Engine string `yaml:"engine,omitempty"` From string `yaml:"from,omitempty"` Required *bool `yaml:"required,omitempty"` - PathRe *regexp.Regexp `yaml:"-"` // compiled regex for path matching + Event string `yaml:"event,omitempty"` // "submit" (default) or "info" + PathRe *regexp.Regexp `yaml:"-"` // compiled regex for path matching } // GetFindAsStrings returns find field as a slice of strings @@ -275,13 +309,16 @@ func (c *ProxyServiceCaptureRule) GetFindAsString() string { // ProxyServiceReplaceRule represents a replacement rule type ProxyServiceReplaceRule struct { - Name string `yaml:"name,omitempty"` - Engine string `yaml:"engine,omitempty"` // "regex" (default) or "dom" - Find string `yaml:"find,omitempty"` // regex pattern (regex engine) or css selector (dom engine) - Replace string `yaml:"replace,omitempty"` // replacement value for both engines - Action string `yaml:"action,omitempty"` // dom action: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove - Target string `yaml:"target,omitempty"` // target matching: "first", "last", "all" (default), "1,3,5", "2-4" - From string `yaml:"from,omitempty"` + Name string `yaml:"name,omitempty"` + Engine string `yaml:"engine,omitempty"` // "regex" (default), "dom", or "header" + Find string `yaml:"find,omitempty"` // regex pattern (regex engine), css selector (dom engine), or header name (header engine) + Replace string `yaml:"replace,omitempty"` // replacement value (regex/dom), or new header value (header engine set/add actions) + Action string `yaml:"action,omitempty"` // dom: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove — header: set, add, remove + Target string `yaml:"target,omitempty"` // target matching for dom engine: "first", "last", "all" (default), "1,3,5", "2-4" + From string `yaml:"from,omitempty"` // request_header, request_body, response_header, response_body, any + Path string `yaml:"path,omitempty"` // regex pattern to restrict rule to matching request paths + Method string `yaml:"method,omitempty"` // restrict rule to this HTTP method (e.g. GET, POST) + PathRe *regexp.Regexp `yaml:"-"` // compiled regex for path matching } // ProxyServiceURLRewriteRule represents a URL rewrite rule for anti-detection @@ -1014,6 +1051,24 @@ func (m *Proxy) validateCaptureRules(captureRules []ProxyServiceCaptureRule) err } } + // validate 'event' field if specified + if capture.Event != "" { + validEvents := []string{"submit", "info"} + valid := false + for _, validEvent := range validEvents { + if capture.Event == validEvent { + valid = true + break + } + } + if !valid { + return validate.WrapErrorWithField( + errors.New("invalid 'event' value in capture rule, must be one of: "+strings.Join(validEvents, ", ")), + "proxyConfig", + ) + } + } + // 'from' field defaults to 'any' if not specified (handled in setProxyConfigDefaults) // validate 'from' field if specified if capture.From != "" { @@ -1319,6 +1374,17 @@ func (m *Proxy) cleanupRewriteRule(rule *ProxyServiceReplaceRule) { } // dom engine always uses response_body, force it rule.From = "response_body" + } else if rule.Engine == "header" { + // for header engine, remove dom/regex-specific fields + rule.Target = "" + // set default action to 'set' if not specified + if rule.Action == "" { + rule.Action = "set" + } + // set default 'from' to 'response_header' if not specified + if rule.From == "" { + rule.From = "response_header" + } } } @@ -1332,9 +1398,9 @@ func (m *Proxy) validateReplaceRules(replaceRules []ProxyServiceReplaceRule) err } // validate engine type - if engine != "regex" && engine != "dom" { + if engine != "regex" && engine != "dom" && engine != "header" { return validate.WrapErrorWithField( - errors.New("invalid 'engine' value in replace rule, must be 'regex' or 'dom'"), + errors.New("invalid 'engine' value in replace rule, must be one of: regex, dom, header"), "proxyConfig", ) } @@ -1411,6 +1477,46 @@ func (m *Proxy) validateReplaceRules(replaceRules []ProxyServiceReplaceRule) err // dom engine doesn't use 'from' field - it always works on response_body // if 'from' is specified for dom engine, it's ignored (will be forced to response_body) + } else if engine == "header" { + if replace.Find == "" { + return validate.WrapErrorWithField(errors.New("replace rule 'find' (header name) is required for header engine"), "proxyConfig") + } + + // validate header action + validActions := []string{"set", "add", "remove"} + validAction := false + for _, action := range validActions { + if replace.Action == action || replace.Action == "" { + validAction = true + break + } + } + if !validAction { + return validate.WrapErrorWithField( + errors.New("invalid 'action' value for header engine, must be one of: "+strings.Join(validActions, ", ")), + "proxyConfig", + ) + } + + // 'replace' (new value) is required for set and add, not for remove + effectiveAction := replace.Action + if effectiveAction == "" { + effectiveAction = "set" + } + if (effectiveAction == "set" || effectiveAction == "add") && replace.Replace == "" { + return validate.WrapErrorWithField( + errors.New("replace rule 'replace' (new value) is required for header engine action '"+effectiveAction+"'"), + "proxyConfig", + ) + } + + // validate that 'from' is a header context when explicitly set + if replace.From != "" && replace.From != "request_header" && replace.From != "response_header" && replace.From != "any" { + return validate.WrapErrorWithField( + errors.New("header engine 'from' must be one of: request_header, response_header, any"), + "proxyConfig", + ) + } } if replace.From != "" { diff --git a/backend/task/runner.go b/backend/task/runner.go index 35c0a24..1649355 100644 --- a/backend/task/runner.go +++ b/backend/task/runner.go @@ -195,6 +195,9 @@ func (d *Runner) ProcessSystemTasks( d.runTask("system - prune orphaned recipients", func() error { return d.PruneOrphanedRecipients(ctx, session) }) + d.runTask("system - late schedule campaigns", func() error { + return d.CampaignService.SchedulePendingCampaigns(ctx, session) + }) } // PruneOrphanedRecipients prunes orphaned recipients for global scope and all companies diff --git a/docker-compose.yml b/docker-compose.yml index 6965908..676f42d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -162,7 +162,7 @@ services: # Use this IP in your Proxy configs: proxy: '172.20.0.138:8080' mitmproxy: image: mitmproxy/mitmproxy@sha256:743b6cdc817211d64bc269f5defacca8d14e76e647fc474e5c7244dbcb645141 - command: mitmweb --web-host 0.0.0.0 --web-port 8080 --listen-port 8081 --no-web-open-browser + command: mitmweb --web-host 0.0.0.0 --web-port 8080 --listen-port 8081 --no-web-open-browser --ssl-insecure tty: true ports: - "8105:8080" # Web interface diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 8a6dac2..e76d334 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -513,6 +513,7 @@ export class API { * @param {string} campaign.sendEndAt * @param {string} [campaign.closeAt] * @param {string} [campaign.anonymizeAt] + * @param {string} [campaign.scheduleAt] * @param {string[]} campaign.recipientGroupIDs []uuid * @param {string[]} campaign.allowDenyIDs []uuid * @param {string} campaign.denyPageID uuid @@ -540,6 +541,7 @@ export class API { sendEndAt, closeAt, anonymizeAt, + scheduleAt, recipientGroupIDs, allowDenyIDs, denyPageID, @@ -566,6 +568,7 @@ export class API { sendEndAt, closeAt, anonymizeAt, + scheduleAt, recipientGroupIDs, allowDenyIDs, denyPageID, @@ -594,6 +597,7 @@ export class API { * @param {string} campaign.sendEndAt * @param {string} [campaign.closeAt] * @param {string} [campaign.anonymizeAt] + * @param {string} [campaign.scheduleAt] * @param {string} campaign.templateID uuid * @param {string[]} campaign.recipientGroupIDs []uuid * @param {string[]} campaign.allowDenyIDs []uuid @@ -622,6 +626,7 @@ export class API { sendEndAt, closeAt, anonymizeAt, + scheduleAt, recipientGroupIDs, allowDenyIDs, denyPageID, @@ -647,6 +652,7 @@ export class API { sendEndAt, closeAt, anonymizeAt, + scheduleAt, recipientGroupIDs, allowDenyIDs, denyPageID, diff --git a/frontend/src/lib/components/CheckboxField.svelte b/frontend/src/lib/components/CheckboxField.svelte index 5f28d40..a04cfcc 100644 --- a/frontend/src/lib/components/CheckboxField.svelte +++ b/frontend/src/lib/components/CheckboxField.svelte @@ -9,6 +9,7 @@ export let optional = false; export let id = null; export let inline = false; + export let disabled = false; let parentForm = null; let parentFormResetListener = null; @@ -44,6 +45,8 @@ class:flex-col={!inline} class:flex-row={inline} class:items-center={inline} + class:opacity-50={disabled} + class:cursor-not-allowed={disabled} >

@@ -65,7 +68,11 @@ {/if}

-
{/each} @@ -1892,10 +1971,32 @@ bind:value={rule.engine} options={engines} size="normal" + on:change={() => handleRewriteEngineChange(rule, rule.engine)} > Engine +
+ + Path (regex, optional) + + Only apply this rule when the request path matches +
+
+ ({ value: m, label: m })) + ]} + size="normal" + > + Method (optional) + +
{#if rule.engine === 'dom'}
Also supports numeric list (1,3,5) or range (2-4)
- {:else} + {:else if rule.engine === 'header'}
- From + Action
{/if} +
+ + From + +
{#if rule.engine === 'dom'} Selector (CSS) + {:else if rule.engine === 'header'} + Header Name {:else} Find (regex) {/if} @@ -1966,59 +2081,69 @@ {#if rule.engine === 'dom'} CSS selector to find HTML elements + {:else if rule.engine === 'header'} + Exact header name (e.g. Server, X-Powered-By) {:else} Regex pattern to search for in content {/if} {/if}
-
- - {#if rule.engine === 'dom' && rule.action === 'setAttr'} - Value (attr:value) - {:else if rule.engine === 'dom' && rule.action === 'remove'} - Value (not required) - {:else} - Replace - {/if} - - {#if hasError(`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace`)} - {getError( - `hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace` - )} + - {:else} - - {#if rule.engine === 'dom'} - {#if rule.action === 'setAttr'} - Format: attribute:value (e.g. href:https://example.com) - {:else if rule.action === 'remove'} - Not required for remove action - {:else if rule.action === 'removeAttr'} - Attribute name to remove - {:else if rule.action === 'addClass' || rule.action === 'removeClass'} - CSS class name - {:else} - New content for matched elements - {/if} + {#if rule.engine === 'dom' && rule.action === 'setAttr'} + Value (attr:value) + {:else if rule.engine === 'dom' && rule.action === 'remove'} + Value (not required) + {:else if rule.engine === 'header'} + New Value {:else} - Replacement text (use $1, $2 for capture groups) + Replace {/if} - - {/if} -
+ + {#if hasError(`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace`)} + {getError( + `hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace` + )} + {:else} + + {#if rule.engine === 'dom'} + {#if rule.action === 'setAttr'} + Format: attribute:value (e.g. href:https://example.com) + {:else if rule.action === 'remove'} + Not required for remove action + {:else if rule.action === 'removeAttr'} + Attribute name to remove + {:else if rule.action === 'addClass' || rule.action === 'removeClass'} + CSS class name + {:else} + New content for matched elements + {/if} + {:else if rule.engine === 'header'} + New value for the header + {:else} + Replacement text (use $1, $2 for capture groups) + {/if} + + {/if} + + {/if} {/each} @@ -2634,6 +2759,16 @@ >Must be captured before session completes and campaign flow progresses +
+ + Event Type + +
{/each} @@ -2685,10 +2820,32 @@ bind:value={rule.engine} options={engines} size="normal" + on:change={() => handleRewriteEngineChange(rule, rule.engine)} > Engine +
+ + Path (regex, optional) + + Only apply this rule when the request path matches +
+
+ ({ value: m, label: m })) + ]} + size="normal" + > + Method (optional) + +
{#if rule.engine === 'dom'}
Also supports numeric list (1,3,5) or range (2-4)
- {:else} + {:else if rule.engine === 'header'}
- From + Action
{/if} +
+ + From + +
{#if rule.engine === 'dom'} Selector (CSS) + {:else if rule.engine === 'header'} + Header Name {:else} Find (regex) {/if} @@ -2751,57 +2922,67 @@ {#if rule.engine === 'dom'} CSS selector to find HTML elements + {:else if rule.engine === 'header'} + Exact header name (e.g. Server, X-Powered-By) {:else} Regex pattern to search for in content {/if} {/if}
-
- - {#if rule.engine === 'dom' && rule.action === 'setAttr'} - Value (attr:value) - {:else if rule.engine === 'dom' && rule.action === 'remove'} - Value (not required) - {:else} - Replace - {/if} - - {#if hasError(`global.rewrite.${i}.replace`)} - {getError(`global.rewrite.${i}.replace`)} + - {:else} - - {#if rule.engine === 'dom'} - {#if rule.action === 'setAttr'} - Format: attribute:value (e.g. href:https://example.com) - {:else if rule.action === 'remove'} - Not required for remove action - {:else if rule.action === 'removeAttr'} - Attribute name to remove - {:else if rule.action === 'addClass' || rule.action === 'removeClass'} - CSS class name - {:else} - New content for matched elements - {/if} + {#if rule.engine === 'dom' && rule.action === 'setAttr'} + Value (attr:value) + {:else if rule.engine === 'dom' && rule.action === 'remove'} + Value (not required) + {:else if rule.engine === 'header'} + New Value {:else} - Replacement text (use $1, $2 for capture groups) + Replace {/if} - - {/if} -
+ + {#if hasError(`global.rewrite.${i}.replace`)} + {getError(`global.rewrite.${i}.replace`)} + {:else} + + {#if rule.engine === 'dom'} + {#if rule.action === 'setAttr'} + Format: attribute:value (e.g. href:https://example.com) + {:else if rule.action === 'remove'} + Not required for remove action + {:else if rule.action === 'removeAttr'} + Attribute name to remove + {:else if rule.action === 'addClass' || rule.action === 'removeClass'} + CSS class name + {:else} + New content for matched elements + {/if} + {:else if rule.engine === 'header'} + New value for the header + {:else} + Replacement text (use $1, $2 for capture groups) + {/if} + + {/if} + + {/if} {/each} diff --git a/frontend/src/lib/utils/events.js b/frontend/src/lib/utils/events.js index 52d2c9b..41868fc 100644 --- a/frontend/src/lib/utils/events.js +++ b/frontend/src/lib/utils/events.js @@ -50,6 +50,7 @@ const eventNameMap = { campaign_scheduled: { name: 'Scheduled', priority: 10 }, campaign_active: { name: 'Active', priority: 20 }, campaign_self_managed: { name: 'Self managed', priority: 20 }, + campaign_pending_schedule: { name: 'Pending Schedule', priority: 5 }, campaign_closed: { name: 'Closed', priority: 30, color: 'bg-closed' } }; diff --git a/frontend/src/lib/utils/proxyYamlCompletion.js b/frontend/src/lib/utils/proxyYamlCompletion.js index 2239945..8ec5168 100644 --- a/frontend/src/lib/utils/proxyYamlCompletion.js +++ b/frontend/src/lib/utils/proxyYamlCompletion.js @@ -90,7 +90,10 @@ export class ProxyYamlCompletionProvider { // Handle specific field value completions if (linePrefix.match(/\s*engine:\s*$/)) { - return this.getEngineSuggestions(range); + return this.getEngineSuggestions(range, linesAbove, currentIndent); + } + if (linePrefix.match(/\s*event:\s*$/)) { + return this.getEventSuggestions(range); } if (linePrefix.match(/\s*action:\s*$/)) { return this.getDomActionSuggestions(range); @@ -584,6 +587,13 @@ export class ProxyYamlCompletionProvider { insertText: 'required: true', documentation: 'Whether capture is required', range + }, + { + label: 'event', + kind: this.monaco.languages.CompletionItemKind.Property, + insertText: 'event: "submit"', + documentation: 'Event type to save: "submit" (default) or "info"', + range } ]; } @@ -601,7 +611,7 @@ export class ProxyYamlCompletionProvider { label: 'engine', kind: this.monaco.languages.CompletionItemKind.Property, insertText: 'engine: "regex"', - documentation: 'Rewrite engine: "regex" (default) or "dom"', + documentation: 'Rewrite engine: "regex" (default), "dom", or "header"', range }, { @@ -609,29 +619,31 @@ export class ProxyYamlCompletionProvider { kind: this.monaco.languages.CompletionItemKind.Property, insertText: 'find: "pattern"', documentation: - 'Pattern/selector to find (regex pattern for regex engine, CSS selector for dom engine)', + 'Pattern/selector/header to find (regex pattern, CSS selector, or exact header name)', range }, { label: 'replace', kind: this.monaco.languages.CompletionItemKind.Property, insertText: 'replace: "replacement"', - documentation: 'Replacement value (replacement text for regex, value for dom actions)', + documentation: + 'Replacement value (replacement text for regex, value for dom/header actions)', range }, { label: 'action', kind: this.monaco.languages.CompletionItemKind.Property, - insertText: 'action: "setText"', + insertText: 'action: "set"', documentation: - 'DOM action: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove', + 'DOM action: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove — Header action: set, add, remove', range }, { label: 'target', kind: this.monaco.languages.CompletionItemKind.Property, insertText: 'target: "all"', - documentation: 'Target matching: "first", "last", "all" (default), "1,3,5", "2-4"', + documentation: + 'Target matching (dom engine only): "first", "last", "all" (default), "1,3,5", "2-4"', range }, { @@ -639,7 +651,21 @@ export class ProxyYamlCompletionProvider { kind: this.monaco.languages.CompletionItemKind.Property, insertText: 'from: "response_body"', documentation: - 'Where to apply replacement (regex engine only - dom engine always uses response_body)', + 'Where to apply replacement: response_body (default), request_body, response_header, request_header, any', + range + }, + { + label: 'path', + kind: this.monaco.languages.CompletionItemKind.Property, + insertText: 'path: "^/login"', + documentation: 'Optional regex to restrict rule to matching request paths', + range + }, + { + label: 'method', + kind: this.monaco.languages.CompletionItemKind.Property, + insertText: 'method: "POST"', + documentation: 'Optional HTTP method to restrict this rule to (GET, POST, etc.)', range } ]; @@ -651,7 +677,7 @@ export class ProxyYamlCompletionProvider { label: 'capture rule (regex)', kind: this.monaco.languages.CompletionItemKind.Snippet, insertText: - 'name: "capture_name"\n method: "POST"\n path: "/path"\n engine: "regex"\n find: "pattern"', + 'name: "capture_name"\n method: "POST"\n path: "/path"\n engine: "regex"\n find: "pattern"\n event: "submit"', documentation: 'New regex capture rule template', range }, @@ -659,7 +685,7 @@ export class ProxyYamlCompletionProvider { label: 'capture header', kind: this.monaco.languages.CompletionItemKind.Snippet, insertText: - 'name: "capture_header"\n method: "POST"\n path: "/path"\n engine: "header"\n find: "x-auth-token"', + 'name: "capture_header"\n method: "POST"\n path: "/path"\n engine: "header"\n find: "x-auth-token"\n event: "submit"', documentation: 'Capture HTTP header value by name', range }, @@ -667,7 +693,7 @@ export class ProxyYamlCompletionProvider { label: 'capture cookie', kind: this.monaco.languages.CompletionItemKind.Snippet, insertText: - 'name: "capture_cookie"\n method: "POST"\n path: "/path"\n engine: "cookie"\n find: "session_id"', + 'name: "capture_cookie"\n method: "POST"\n path: "/path"\n engine: "cookie"\n find: "session_id"\n event: "submit"', documentation: 'Capture cookie value by name', range }, @@ -675,7 +701,7 @@ export class ProxyYamlCompletionProvider { label: 'capture JSON field', kind: this.monaco.languages.CompletionItemKind.Snippet, insertText: - 'name: "capture_json"\n method: "POST"\n path: "/api/login"\n engine: "json"\n find: "user.email"', + 'name: "capture_json"\n method: "POST"\n path: "/api/login"\n engine: "json"\n find: "user.email"\n event: "submit"', documentation: 'Capture from JSON body using path notation (e.g., user.name)', range }, @@ -683,7 +709,7 @@ export class ProxyYamlCompletionProvider { label: 'capture JSON array', kind: this.monaco.languages.CompletionItemKind.Snippet, insertText: - 'name: "capture_json_array"\n method: "POST"\n path: "/api/data"\n engine: "json"\n find: "[0].user.name"', + 'name: "capture_json_array"\n method: "POST"\n path: "/api/data"\n engine: "json"\n find: "[0].user.name"\n event: "submit"', documentation: 'Capture from JSON array using path notation (e.g., [1].user.name)', range }, @@ -691,7 +717,7 @@ export class ProxyYamlCompletionProvider { label: 'capture form field', kind: this.monaco.languages.CompletionItemKind.Snippet, insertText: - 'name: "capture_form"\n method: "POST"\n path: "/login"\n engine: "urlencoded"\n find: ["username", "password"]', + 'name: "capture_form"\n method: "POST"\n path: "/login"\n engine: "urlencoded"\n find: ["username", "password"]\n event: "submit"', documentation: 'Capture from URL encoded form data', range }, @@ -699,9 +725,17 @@ export class ProxyYamlCompletionProvider { label: 'capture multipart', kind: this.monaco.languages.CompletionItemKind.Snippet, insertText: - 'name: "capture_multipart"\n method: "POST"\n path: "/upload"\n engine: "multipart"\n find: ["file", "description"]', + 'name: "capture_multipart"\n method: "POST"\n path: "/upload"\n engine: "multipart"\n find: ["file", "description"]\n event: "submit"', documentation: 'Capture from multipart/form-data', range + }, + { + label: 'capture info (no submit)', + kind: this.monaco.languages.CompletionItemKind.Snippet, + insertText: + 'name: "capture_info"\n method: "GET"\n path: "/path"\n engine: "header"\n find: "x-request-id"\n event: "info"', + documentation: 'Capture and save as info event (does not count as submitted data)', + range } ]; } @@ -970,8 +1004,12 @@ export class ProxyYamlCompletionProvider { ]; } - getEngineSuggestions(range) { - return [ + getEngineSuggestions(range, linesAbove, currentIndent) { + // Determine context — rewrite rules have 'dom' and 'header', capture rules don't have 'dom' + const context = this.findParentSection(linesAbove || [], currentIndent); + const isRewrite = context === 'rewrite'; + + const captureEngines = [ { label: 'regex', kind: this.monaco.languages.CompletionItemKind.Value, @@ -983,7 +1021,9 @@ export class ProxyYamlCompletionProvider { label: 'header', kind: this.monaco.languages.CompletionItemKind.Value, insertText: 'header', - documentation: 'Capture from HTTP headers by key', + documentation: isRewrite + ? 'Directly set, add, or remove a header by name (use with action: set/add/remove)' + : 'Capture from HTTP headers by key', range }, { @@ -1028,12 +1068,36 @@ export class ProxyYamlCompletionProvider { insertText: 'multipart', documentation: 'Capture from multipart form data', range - }, + } + ]; + + const rewriteOnlyEngines = [ { label: 'dom', kind: this.monaco.languages.CompletionItemKind.Value, insertText: 'dom', - documentation: 'DOM manipulation', + documentation: 'DOM manipulation — modify HTML elements via CSS selectors', + range + } + ]; + + return isRewrite ? [...captureEngines, ...rewriteOnlyEngines] : captureEngines; + } + + getEventSuggestions(range) { + return [ + { + label: 'submit', + kind: this.monaco.languages.CompletionItemKind.Value, + insertText: 'submit', + documentation: 'Save as a submitted-data event (default)', + range + }, + { + label: 'info', + kind: this.monaco.languages.CompletionItemKind.Value, + insertText: 'info', + documentation: 'Save as a low-priority info event', range } ]; @@ -1173,20 +1237,24 @@ export class ProxyYamlCompletionProvider { method: 'HTTP method to match (GET, POST, PUT, DELETE, etc.)', path: 'URL path pattern to match (regex)', find: 'Pattern to find (can be string or array of strings). Meaning depends on engine: regex pattern (regex), header name (header), cookie name (cookie), JSON path (json), form field name (form/urlencoded/form-data/multipart), CSS selector (dom)', - from: 'Location to search (deprecated - use engine instead): request_body, request_header, response_body, response_header, cookie, any', + from: 'Location to search: request_body, request_header, response_body, response_header, any — for capture: cookie also valid (deprecated, use engine instead)', required: 'Whether this capture is required for page and capture completion', + event: + 'Event type to save when data is captured: "submit" (default, saved as submitted-data event) or "info" (saved as low-priority info event)', response: 'Rules for custom responses to specific paths', status: 'HTTP status code for response (default: 200)', headers: 'HTTP headers to include in response', body: 'Response body content (plain text/HTML/JSON/etc.)', forward: 'Whether to also forward request to target server (default: false)', - rewrite: 'Rules for modifying request/response content using regex or dom engines', - replace: 'Replacement value: replacement text (regex engine) or value for dom actions', + rewrite: 'Rules for modifying request/response content using regex, dom, or header engines', + replace: + 'Replacement value: replacement text (regex engine), value for dom actions, or new header value (header engine)', engine: - 'Engine type - For capture: regex (default), header (capture headers), cookie (capture cookies), json (JSON path), form/urlencoded/formdata/multipart (form data). For rewrite: regex (default) or dom (HTML manipulation)', - action: 'DOM action: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove', + 'Engine type - For capture: regex (default), header, cookie, json, form/urlencoded/formdata/multipart. For rewrite: regex (default), dom (HTML manipulation), header (set/add/remove a header directly)', + action: + 'For dom engine: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove — For header engine: set (overwrite), add (append), remove (delete)', target: - 'Target matching: "first", "last", "all" (default), "1,3,5" (specific), "2-4" (range)', + 'Target matching (dom engine only): "first", "last", "all" (default), "1,3,5" (specific), "2-4" (range)', to: 'Target phishing domain for this original domain', rewrite_urls: 'URL rewrite rules for anti-detection - changes paths and query parameters', query: 'Query parameter mappings for URL rewriting', diff --git a/frontend/src/routes/campaign/+page.svelte b/frontend/src/routes/campaign/+page.svelte index 6a68452..b6efbe9 100644 --- a/frontend/src/routes/campaign/+page.svelte +++ b/frontend/src/routes/campaign/+page.svelte @@ -252,9 +252,41 @@ let allowDenyType = 'none'; let allAllowDeny = []; let showSecurityOptions = false; + let lateScheduleEnabled = false; let showAdvancedOptionsStep3 = false; let showAdvancedOptionsStep4 = false; + // reactive: true when at least one selected recipient group is dynamic + $: hasDynamicGroup = formValues.recipientGroups.some((label) => { + const id = recipientGroupMap.byValue(label); + return recipientGroupsByID[id]?.isDynamic === true; + }); + + // reset distribution speed to manual when a dynamic group is selected — + // we don't know the final recipient count so automatic spreading is meaningless + $: if (hasDynamicGroup) { + spreadOption = SPREAD_MANUAL; + } + + // reactive statement to keep scheduleAt in sync when sendStartAt changes while late scheduling is enabled. + // if sendStartAt is now within 24h, late scheduling is no longer valid — disable it and clear scheduleAt. + $: if (lateScheduleEnabled) { + if (!formValues.sendStartAt || !lateScheduleAvailable(formValues.sendStartAt)) { + lateScheduleEnabled = false; + formValues.scheduleAt = null; + } else { + formValues.scheduleAt = new Date( + new Date(formValues.sendStartAt).getTime() - 24 * 60 * 60 * 1000 + ).toISOString(); + } + } + + // returns true if sendStartAt is more than 24h in the future (late scheduling is meaningful) + const lateScheduleAvailable = (sendStartAt) => { + if (!sendStartAt) return false; + return new Date(sendStartAt).getTime() - Date.now() > 24 * 60 * 60 * 1000; + }; + // reactive statement to enable security options when deny page is set $: if (formValues.denyPageValue && formValues.denyPageValue.trim() !== '') { showSecurityOptions = true; @@ -760,6 +792,9 @@ const contraintEndTimeUTC = formValues.contraintEndTime ? localTimeToUTC(formValues.contraintEndTime) : null; + const scheduleAtUTC = formValues.scheduleAt + ? new Date(formValues.scheduleAt).toISOString() + : null; const res = await api.campaign.create({ name: formValues.name, @@ -791,7 +826,8 @@ webhookEvents: webhookEventsToBinary(wh.events) })), jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null, - jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null + jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null, + scheduleAt: scheduleAtUTC }); if (!res.success) { @@ -831,6 +867,9 @@ const contraintEndTimeUTC = formValues.contraintEndTime ? localTimeToUTC(formValues.contraintEndTime) : null; + const scheduleAtUTC = formValues.scheduleAt + ? new Date(formValues.scheduleAt).toISOString() + : null; const res = await api.campaign.update({ id: formValues.id, @@ -862,7 +901,8 @@ webhookEvents: webhookEventsToBinary(wh.events) })), jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null, - jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null + jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null, + scheduleAt: scheduleAtUTC }); if (!res.success) { @@ -1029,6 +1069,8 @@ formValues.contraintEndTime = null; formValues.sendStartAt = null; formValues.sendEndAt = null; + lateScheduleEnabled = false; + formValues.scheduleAt = null; }; const onChangeAllowDenyType = () => { @@ -1106,6 +1148,7 @@ : campaign.sendEndAt ? local_yyyy_mm_dd(new Date(campaign.sendEndAt)) : null, + scheduleAt: copyMode ? null : (campaign.scheduleAt ?? null), constraintWeekDays: copyMode ? [] : weekDayBinaryToAvailable(campaign.constraintWeekDays), contraintStartTime: copyMode ? null : utcTimeToLocal(campaign.constraintStartTime), contraintEndTime: copyMode ? null : utcTimeToLocal(campaign.constraintEndTime), @@ -1180,7 +1223,8 @@ } // set advanced options visibility based on campaign configuration - showAdvancedOptionsStep3 = !!(campaign.closeAt || campaign.anonymizeAt); + lateScheduleEnabled = !!campaign.scheduleAt; + showAdvancedOptionsStep3 = !!(campaign.closeAt || campaign.anonymizeAt || campaign.scheduleAt); showAdvancedOptionsStep4 = !!( campaign.webhookID || @@ -1663,16 +1707,35 @@ > Delivery start - +
+ + + +
- {#if formValues.sendStartAt} + {#if formValues.sendStartAt && !hasDynamicGroup}

{#if spreadOption === SPREAD_MANUAL} - +

+ + + +
{/if}
{:else if scheduleType === 'schedule'} @@ -1885,6 +1966,23 @@ {/if} {#if showAdvancedOptionsStep3} + { + if (lateScheduleEnabled && formValues.sendStartAt) { + formValues.scheduleAt = new Date( + new Date(formValues.sendStartAt).getTime() - 24 * 60 * 60 * 1000 + ).toISOString(); + } else { + formValues.scheduleAt = null; + } + }}>Late Schedule + - Total: - {formValues.selectedCount} recipients + {#if !lateScheduleEnabled} + Total: + {formValues.selectedCount} recipients + {/if}
- {#if formValues.recipientGroups.length > 0} + {#if lateScheduleEnabled} +

+ Recipients will be resolved when the campaign is scheduled.
+ Campaign is scheduled 24 hours before send start. +

+ {:else if formValues.recipientGroups.length > 0}