Merge branch 'develop' into feat-scim

This commit is contained in:
Ronni Skansing
2026-04-02 11:51:06 +02:00
18 changed files with 1374 additions and 367 deletions
+5 -4
View File
@@ -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
+6 -4
View File
@@ -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,
+9
View File
@@ -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
+22 -1
View File
@@ -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
}
+278 -21
View File
@@ -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
+56
View File
@@ -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,
+1
View File
@@ -437,6 +437,7 @@ func (r *Recipient) GetOrphaned(
database.RECIPIENT_TABLE,
database.RECIPIENT_TABLE,
database.RECIPIENT_TABLE,
database.RECIPIENT_TABLE,
)
query := fmt.Sprintf(
+177 -15
View File
@@ -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.
+117 -11
View File
@@ -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 != "" {
+3
View File
@@ -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
+1 -1
View File
@@ -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
+6
View File
@@ -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,
@@ -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}
>
<div class="flex items-center">
<p class="font-semibold text-slate-600 dark:text-gray-400 py-2 transition-colors duration-200">
@@ -65,7 +68,11 @@
{/if}
</div>
<div class="mt-1" class:mt-0={inline} class:ml-3={inline}>
<label class="relative flex items-center cursor-pointer">
<label
class="relative flex items-center"
class:cursor-pointer={!disabled}
class:cursor-not-allowed={disabled}
>
<input
{id}
type="checkbox"
@@ -74,6 +81,7 @@
bind:checked={value}
on:change
tabindex="0"
{disabled}
/>
<div
class="w-5 h-5 border-2 border-slate-300 dark:border-gray-700/60 rounded
@@ -224,6 +224,7 @@
};
configData.global.capture = (parsed.global.capture || []).map((r) => ({
...r,
event: r.event || 'submit',
_id: getRuleId()
}));
configData.global.rewrite = (parsed.global.rewrite || []).map((r) => ({
@@ -261,7 +262,11 @@
mode: hostData.access?.mode || '',
on_deny: hostData.access?.on_deny || ''
},
capture: (hostData.capture || []).map((r) => ({ ...r, _id: getRuleId() })),
capture: (hostData.capture || []).map((r) => ({
...r,
event: r.event || 'submit',
_id: getRuleId()
})),
rewrite: (hostData.rewrite || []).map((r) => ({ ...r, _id: getRuleId() })),
response: (hostData.response || []).map((r) => ({ ...r, _id: getRuleId() })),
rewrite_urls: (hostData.rewrite_urls || []).map((r) => ({ ...r, _id: getRuleId() }))
@@ -495,6 +500,11 @@
}
// global rule management
const captureEventOptions = [
{ value: 'submit', label: 'Submit' },
{ value: 'info', label: 'Info' }
];
function addGlobalCaptureRule() {
configData.global.capture = [
...configData.global.capture,
@@ -506,7 +516,8 @@
find: '',
engine: 'regex',
from: 'request_body',
required: false
required: false,
event: 'submit'
}
];
}
@@ -518,7 +529,17 @@
function addGlobalRewriteRule() {
configData.global.rewrite = [
...configData.global.rewrite,
{ _id: getRuleId(), name: '', engine: 'regex', find: '', replace: '', from: 'response_body' }
{
_id: getRuleId(),
name: '',
engine: 'regex',
find: '',
replace: '',
from: 'response_body',
action: '',
path: '',
method: ''
}
];
}
@@ -560,7 +581,8 @@
find: '',
engine: 'regex',
from: 'request_body',
required: false
required: false,
event: 'submit'
}
];
configData.hosts = [...configData.hosts];
@@ -576,7 +598,17 @@
function addHostRewriteRule(hostIndex) {
configData.hosts[hostIndex].rewrite = [
...(configData.hosts[hostIndex].rewrite || []),
{ _id: getRuleId(), name: '', engine: 'regex', find: '', replace: '', from: 'response_body' }
{
_id: getRuleId(),
name: '',
engine: 'regex',
find: '',
replace: '',
from: 'response_body',
action: '',
path: '',
method: ''
}
];
configData.hosts = [...configData.hosts];
}
@@ -709,8 +741,40 @@
];
const engines = [
{ value: 'regex', label: 'Regex' },
{ value: 'dom', label: 'DOM' }
{ value: 'dom', label: 'DOM' },
{ value: 'header', label: 'Header' }
];
const headerActions = [
{ value: 'set', label: 'Set' },
{ value: 'add', label: 'Add' },
{ value: 'remove', label: 'Remove' }
];
const rewriteHeaderFromOptions = [
{ value: 'request_header', label: 'Request Header' },
{ value: 'response_header', label: 'Response Header' },
{ value: 'any', label: 'Any' }
];
function getFromOptionsForRewriteEngine(engine) {
if (engine === 'header') return rewriteHeaderFromOptions;
return fromOptions;
}
function getDefaultFromForRewriteEngine(engine) {
if (engine === 'header') return 'response_header';
return 'response_body';
}
function handleRewriteEngineChange(rule, newEngine) {
rule.engine = newEngine;
rule.from = getDefaultFromForRewriteEngine(newEngine);
if (newEngine !== 'dom') {
rule.action = newEngine === 'header' ? 'set' : '';
}
configData = configData;
}
const domActions = [
{ value: 'setText', label: 'Set Text' },
{ value: 'setHtml', label: 'Set HTML' },
@@ -751,7 +815,7 @@
}
function isRewriteRuleTouched(rule) {
return !!(rule.find?.trim() || rule.replace?.trim());
return !!(rule.find?.trim() || rule.replace?.trim() || rule.path?.trim());
}
function isResponseRuleTouched(rule) {
@@ -1131,6 +1195,7 @@
};
configData.global.capture = (parsed.global.capture || []).map((r) => ({
...r,
event: r.event || 'submit',
_id: getRuleId()
}));
configData.global.rewrite = (parsed.global.rewrite || []).map((r) => ({
@@ -1181,7 +1246,11 @@
mode: hostData.access?.mode || '',
on_deny: hostData.access?.on_deny || ''
},
capture: (hostData.capture || []).map((r) => ({ ...r, _id: getRuleId() })),
capture: (hostData.capture || []).map((r) => ({
...r,
event: r.event || 'submit',
_id: getRuleId()
})),
rewrite: (hostData.rewrite || []).map((r) => ({ ...r, _id: getRuleId() })),
response: (hostData.response || []).map((r) => ({ ...r, _id: getRuleId() })),
rewrite_urls: (hostData.rewrite_urls || []).map((r) => ({ ...r, _id: getRuleId() }))
@@ -1833,6 +1902,16 @@
progresses</span
>
</div>
<div class="field-wrapper">
<TextFieldSelect
id={`host-${expandedHostIndex}-capture-${ruleIndex}-event`}
bind:value={rule.event}
options={captureEventOptions}
size="normal"
>
Event Type
</TextFieldSelect>
</div>
</div>
</div>
{/each}
@@ -1892,10 +1971,32 @@
bind:value={rule.engine}
options={engines}
size="normal"
on:change={() => handleRewriteEngineChange(rule, rule.engine)}
>
Engine
</TextFieldSelect>
</div>
<div class="field-wrapper">
<TextField width="full" bind:value={rule.path} placeholder="^/login">
Path (regex, optional)
</TextField>
<span class="form-hint"
>Only apply this rule when the request path matches</span
>
</div>
<div class="field-wrapper">
<TextFieldSelect
id={`host-${expandedHostIndex}-rewrite-${ruleIndex}-method`}
bind:value={rule.method}
options={[
{ value: '', label: 'Any' },
...methods.map((m) => ({ value: m, label: m }))
]}
size="normal"
>
Method (optional)
</TextFieldSelect>
</div>
{#if rule.engine === 'dom'}
<div class="field-wrapper">
<TextFieldSelect
@@ -1927,31 +2028,45 @@
>Also supports numeric list (1,3,5) or range (2-4)</span
>
</div>
{:else}
{:else if rule.engine === 'header'}
<div class="field-wrapper">
<TextFieldSelect
id={`host-${expandedHostIndex}-rewrite-${ruleIndex}-from`}
bind:value={rule.from}
options={fromOptions}
id={`host-${expandedHostIndex}-rewrite-${ruleIndex}-action`}
bind:value={rule.action}
options={headerActions}
size="normal"
>
From
Action
</TextFieldSelect>
</div>
{/if}
<div class="field-wrapper">
<TextFieldSelect
id={`host-${expandedHostIndex}-rewrite-${ruleIndex}-from`}
bind:value={rule.from}
options={getFromOptionsForRewriteEngine(rule.engine)}
size="normal"
>
From
</TextFieldSelect>
</div>
<div class="field-wrapper full">
<TextField
width="full"
bind:value={rule.find}
placeholder={rule.engine === 'dom'
? 'div.logo, #header img'
: 'target\\.com'}
: rule.engine === 'header'
? 'Server'
: 'target\\.com'}
error={hasError(
`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.find`
)}
>
{#if rule.engine === 'dom'}
Selector (CSS)
{:else if rule.engine === 'header'}
Header Name
{:else}
Find (regex)
{/if}
@@ -1966,59 +2081,69 @@
<span class="form-hint">
{#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}
</span>
{/if}
</div>
<div class="field-wrapper full">
<TextareaField
fullWidth
bind:value={rule.replace}
placeholder={rule.engine === 'dom'
? rule.action === 'setAttr'
? 'href:https://example.com'
: rule.action === 'remove'
? ''
: 'New content'
: 'phishing.com'}
height="medium"
>
{#if rule.engine === 'dom' && rule.action === 'setAttr'}
Value (attr:value)
{:else if rule.engine === 'dom' && rule.action === 'remove'}
Value (not required)
{:else}
Replace
{/if}
</TextareaField>
{#if hasError(`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace`)}
<span class="field-error"
>{getError(
`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace`
)}</span
{#if rule.engine !== 'header' || rule.action !== 'remove'}
<div class="field-wrapper full">
<TextareaField
fullWidth
bind:value={rule.replace}
placeholder={rule.engine === 'dom'
? rule.action === 'setAttr'
? 'href:https://example.com'
: rule.action === 'remove'
? ''
: 'New content'
: rule.engine === 'header'
? 'new-value'
: 'phishing.com'}
height="medium"
>
{:else}
<span class="form-hint">
{#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}
</span>
{/if}
</div>
</TextareaField>
{#if hasError(`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace`)}
<span class="field-error"
>{getError(
`hosts.${expandedHostIndex}.rewrite.${ruleIndex}.replace`
)}</span
>
{:else}
<span class="form-hint">
{#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}
</span>
{/if}
</div>
{/if}
</div>
</div>
{/each}
@@ -2634,6 +2759,16 @@
>Must be captured before session completes and campaign flow progresses</span
>
</div>
<div class="field-wrapper">
<TextFieldSelect
id={`global-capture-${i}-event`}
bind:value={rule.event}
options={captureEventOptions}
size="normal"
>
Event Type
</TextFieldSelect>
</div>
</div>
</div>
{/each}
@@ -2685,10 +2820,32 @@
bind:value={rule.engine}
options={engines}
size="normal"
on:change={() => handleRewriteEngineChange(rule, rule.engine)}
>
Engine
</TextFieldSelect>
</div>
<div class="field-wrapper">
<TextField width="full" bind:value={rule.path} placeholder="^/login">
Path (regex, optional)
</TextField>
<span class="form-hint"
>Only apply this rule when the request path matches</span
>
</div>
<div class="field-wrapper">
<TextFieldSelect
id={`global-rewrite-${i}-method`}
bind:value={rule.method}
options={[
{ value: '', label: 'Any' },
...methods.map((m) => ({ value: m, label: m }))
]}
size="normal"
>
Method (optional)
</TextFieldSelect>
</div>
{#if rule.engine === 'dom'}
<div class="field-wrapper">
<TextFieldSelect
@@ -2718,29 +2875,43 @@
>Also supports numeric list (1,3,5) or range (2-4)</span
>
</div>
{:else}
{:else if rule.engine === 'header'}
<div class="field-wrapper">
<TextFieldSelect
id={`global-rewrite-${i}-from`}
bind:value={rule.from}
options={fromOptions}
id={`global-rewrite-${i}-action`}
bind:value={rule.action}
options={headerActions}
size="normal"
>
From
Action
</TextFieldSelect>
</div>
{/if}
<div class="field-wrapper">
<TextFieldSelect
id={`global-rewrite-${i}-from`}
bind:value={rule.from}
options={getFromOptionsForRewriteEngine(rule.engine)}
size="normal"
>
From
</TextFieldSelect>
</div>
<div class="field-wrapper full">
<TextField
width="full"
bind:value={rule.find}
placeholder={rule.engine === 'dom'
? 'div.logo, #header img'
: 'target\\.com'}
: rule.engine === 'header'
? 'Server'
: 'target\\.com'}
error={hasError(`global.rewrite.${i}.find`)}
>
{#if rule.engine === 'dom'}
Selector (CSS)
{:else if rule.engine === 'header'}
Header Name
{:else}
Find (regex)
{/if}
@@ -2751,57 +2922,67 @@
<span class="form-hint">
{#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}
</span>
{/if}
</div>
<div class="field-wrapper full">
<TextareaField
fullWidth
bind:value={rule.replace}
placeholder={rule.engine === 'dom'
? rule.action === 'setAttr'
? 'href:https://example.com'
: rule.action === 'remove'
? ''
: 'New content'
: 'phishing.com'}
height="medium"
>
{#if rule.engine === 'dom' && rule.action === 'setAttr'}
Value (attr:value)
{:else if rule.engine === 'dom' && rule.action === 'remove'}
Value (not required)
{:else}
Replace
{/if}
</TextareaField>
{#if hasError(`global.rewrite.${i}.replace`)}
<span class="field-error"
>{getError(`global.rewrite.${i}.replace`)}</span
{#if rule.engine !== 'header' || rule.action !== 'remove'}
<div class="field-wrapper full">
<TextareaField
fullWidth
bind:value={rule.replace}
placeholder={rule.engine === 'dom'
? rule.action === 'setAttr'
? 'href:https://example.com'
: rule.action === 'remove'
? ''
: 'New content'
: rule.engine === 'header'
? 'new-value'
: 'phishing.com'}
height="medium"
>
{:else}
<span class="form-hint">
{#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}
</span>
{/if}
</div>
</TextareaField>
{#if hasError(`global.rewrite.${i}.replace`)}
<span class="field-error"
>{getError(`global.rewrite.${i}.replace`)}</span
>
{:else}
<span class="form-hint">
{#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}
</span>
{/if}
</div>
{/if}
</div>
</div>
{/each}
+1
View File
@@ -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' }
};
+94 -26
View File
@@ -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',
+140 -28
View File
@@ -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
</DateTimeField>
<button
class="text-cta-blue hover:text-blue-700 dark:text-white dark:hover:text-gray-200 text-sm transition-colors duration-200"
on:click|preventDefault={() =>
(formValues.sendStartAt = new Date().toISOString())}
>
set to now
</button>
<div class="flex items-center gap-1">
<button
type="button"
class="inline-flex items-center px-2 py-1 border border-slate-300 dark:border-gray-700/60 rounded-md text-xs font-medium text-slate-600 dark:text-gray-300 bg-grayblue-light dark:bg-gray-900/60 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors duration-200"
on:click|preventDefault={() =>
(formValues.sendStartAt = new Date().toISOString())}
>
Now
</button>
<button
type="button"
class="inline-flex items-center px-2 py-1 border border-slate-300 dark:border-gray-700/60 rounded-md text-xs font-medium text-slate-600 dark:text-gray-300 bg-grayblue-light dark:bg-gray-900/60 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors duration-200"
on:click|preventDefault={() =>
(formValues.sendStartAt = new Date(Date.now() + 86400000).toISOString())}
>
+1 Day
</button>
<button
type="button"
class="inline-flex items-center px-2 py-1 border border-slate-300 dark:border-gray-700/60 rounded-md text-xs font-medium text-slate-600 dark:text-gray-300 bg-grayblue-light dark:bg-gray-900/60 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors duration-200"
on:click|preventDefault={() =>
(formValues.sendStartAt = new Date(Date.now() + 604800000).toISOString())}
>
+1 Week
</button>
</div>
</div>
{#if formValues.sendStartAt}
{#if formValues.sendStartAt && !hasDynamicGroup}
<div class="pt-4 pb-6">
<div class="flex flex-col gap-2">
<p
@@ -1756,14 +1819,32 @@
Delivery end
</DateTimeField>
{#if spreadOption === SPREAD_MANUAL}
<button
class="text-cta-blue hover:text-blue-700 dark:text-white dark:hover:text-gray-200 text-sm transition-colors duration-200"
on:click|preventDefault={() => {
formValues.sendEndAt = new Date().toISOString();
}}
>
set to now
</button>
<div class="flex items-center gap-1">
<button
type="button"
class="inline-flex items-center px-2 py-1 border border-slate-300 dark:border-gray-700/60 rounded-md text-xs font-medium text-slate-600 dark:text-gray-300 bg-grayblue-light dark:bg-gray-900/60 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors duration-200"
on:click|preventDefault={() =>
(formValues.sendEndAt = new Date().toISOString())}
>
Now
</button>
<button
type="button"
class="inline-flex items-center px-2 py-1 border border-slate-300 dark:border-gray-700/60 rounded-md text-xs font-medium text-slate-600 dark:text-gray-300 bg-grayblue-light dark:bg-gray-900/60 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors duration-200"
on:click|preventDefault={() =>
(formValues.sendEndAt = new Date(Date.now() + 86400000).toISOString())}
>
+1 Day
</button>
<button
type="button"
class="inline-flex items-center px-2 py-1 border border-slate-300 dark:border-gray-700/60 rounded-md text-xs font-medium text-slate-600 dark:text-gray-300 bg-grayblue-light dark:bg-gray-900/60 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors duration-200"
on:click|preventDefault={() =>
(formValues.sendEndAt = new Date(Date.now() + 604800000).toISOString())}
>
+1 Week
</button>
</div>
{/if}
</div>
{:else if scheduleType === 'schedule'}
@@ -1885,6 +1966,23 @@
{/if}
{#if showAdvancedOptionsStep3}
<CheckboxField
bind:value={lateScheduleEnabled}
disabled={!lateScheduleAvailable(formValues.sendStartAt)}
toolTipText={!lateScheduleAvailable(formValues.sendStartAt)
? 'Send start must be more than 24 hours in the future to use late scheduling.'
: 'When enabled, recipients are resolved and the campaign is scheduled 24 hours before send start, not at creation.'}
on:change={() => {
if (lateScheduleEnabled && formValues.sendStartAt) {
formValues.scheduleAt = new Date(
new Date(formValues.sendStartAt).getTime() - 24 * 60 * 60 * 1000
).toISOString();
} else {
formValues.scheduleAt = null;
}
}}>Late Schedule</CheckboxField
>
<TextFieldSelect
id="sortField"
bind:value={formValues.sortField}
@@ -2251,13 +2349,20 @@
: 'None selected'}
</span>
<span class="text-grayblue-dark font-medium">Total:</span>
<span class="text-pc-darkblue dark:text-white"
>{formValues.selectedCount} recipients</span
>
{#if !lateScheduleEnabled}
<span class="text-grayblue-dark font-medium">Total:</span>
<span class="text-pc-darkblue dark:text-white"
>{formValues.selectedCount} recipients</span
>
{/if}
</div>
{#if formValues.recipientGroups.length > 0}
{#if lateScheduleEnabled}
<p class="text-sm text-amber-600 dark:text-amber-400">
Recipients will be resolved when the campaign is scheduled.<br />
Campaign is scheduled 24 hours before send start.
</p>
{:else if formValues.recipientGroups.length > 0}
<button
type="button"
class="text-xs font-medium text-white dark:text-white hover:text-gray-200 dark:hover:text-gray-300 flex items-center gap-1"
@@ -2377,10 +2482,7 @@
<RelativeTime value={formValues.sendStartAt} />
</span>
<span
class="text-grayblue-dark dark:text-gray-300 font-medium transition-colors duration-200"
>End:</span
>
<span class="text-grayblue-dark font-medium">End:</span>
<span
class="text-pc-darkblue dark:text-gray-100 transition-colors duration-200"
>
@@ -2431,6 +2533,16 @@
{/if}
{/if}
{#if formValues.scheduleAt}
<span class="text-grayblue-dark font-medium">Schedule at:</span>
<span
class="text-pc-darkblue dark:text-gray-100 transition-colors duration-200"
>
<Datetime value={formValues.scheduleAt} />
<RelativeTime value={formValues.scheduleAt} />
</span>
{/if}
{#if formValues.closeAt}
<span class="text-grayblue-dark font-medium">Close at:</span>
<span class="text-pc-darkblue dark:text-white">
+162 -149
View File
@@ -70,6 +70,7 @@
saveSubmittedData: false,
saveBrowserMetadata: false,
isAnonymous: false,
scheduleAt: null,
allowDenyIDs: [],
webhookID: null,
@@ -286,6 +287,7 @@
}
campaign.recipientGroups = t.recipientGroupIDs.map((id) => recipientGroupMap.byKey(id));
campaign.notableEventName = t.notableEventName;
campaign.scheduleAt = t.scheduleAt ?? null;
if (t.sendStartAt === null && t.sendEndAt === null) {
isSelfManaged = true;
}
@@ -1622,6 +1624,15 @@
</div>
{#if !isSelfManaged}
{#if campaign.scheduleAt}
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Schedules at:</span>
<span class="text-pc-darkblue dark:text-white text-right"
><Datetime value={campaign.scheduleAt} /></span
>
</div>
{/if}
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Delivery start:</span>
<span class="text-pc-darkblue dark:text-white text-right"
@@ -1932,172 +1943,174 @@
{/each}
</Table>
</div>
<SubHeadline>Recipients overview</SubHeadline>
<Table
columns={[
{ column: 'First name', size: 'small' },
{ column: 'Last name', size: 'small' },
{ column: 'Email', size: 'large' },
{ column: 'Status', size: 'small' },
{ column: 'Send at', title: 'Scheduled', size: 'small' },
{ column: 'Sent at', title: 'Delivered', size: 'small' },
{ column: 'Cancelled at', size: 'small' }
]}
sortable={[
'First name',
'Last name',
'Email',
'Status',
'Send at',
'Sent at',
'Cancelled at'
]}
pagination={recipientTableUrlParams}
plural="recipients"
hasData={!!campaignRecipients.length}
hasNextPage={campaignRecipientsHasNextPage}
isGhost={isRecipientTableLoading}
>
{#each campaignRecipients as recp (recp.id)}
<TableRow>
{#if recp?.anonymizedID}
<TableCell value={'anonymized'} />
<TableCell value={'anonymized'} />
<TableCell value={'anonymized'} />
{:else}
<TableCell>
<button
on:click={() => openEventsModal(recp.recipientID)}
class="block w-full py-1 text-left"
>
{recp.recipient.firstName}
</button>
</TableCell>
<TableCell>
<button
on:click={() => openEventsModal(recp.recipientID)}
class="block w-full py-1 text-left"
>
{recp.recipient.lastName}
</button>
</TableCell>
<TableCell>
{#if recp?.recipient?.email}
{#if campaign.notableEventName !== 'campaign_pending_schedule'}
<SubHeadline>Recipients overview</SubHeadline>
<Table
columns={[
{ column: 'First name', size: 'small' },
{ column: 'Last name', size: 'small' },
{ column: 'Email', size: 'large' },
{ column: 'Status', size: 'small' },
{ column: 'Send at', title: 'Scheduled', size: 'small' },
{ column: 'Sent at', title: 'Delivered', size: 'small' },
{ column: 'Cancelled at', size: 'small' }
]}
sortable={[
'First name',
'Last name',
'Email',
'Status',
'Send at',
'Sent at',
'Cancelled at'
]}
pagination={recipientTableUrlParams}
plural="recipients"
hasData={!!campaignRecipients.length}
hasNextPage={campaignRecipientsHasNextPage}
isGhost={isRecipientTableLoading}
>
{#each campaignRecipients as recp (recp.id)}
<TableRow>
{#if recp?.anonymizedID}
<TableCell value={'anonymized'} />
<TableCell value={'anonymized'} />
<TableCell value={'anonymized'} />
{:else}
<TableCell>
<button
on:click={() => openEventsModal(recp.recipientID)}
class="block w-full py-1 text-left"
>
{recp.recipient.email}
{recp.recipient.firstName}
</button>
{/if}
</TableCell>
{/if}
<TableCell>
<EventName eventName={recp?.notableEventName} />
</TableCell>
<TableCell value={recp?.sendAt} isDate />
<TableCell value={recp?.sentAt} isDate />
<TableCell value={recp?.cancelledAt} isDate />
{#if !campaign.sentAt}
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableViewButton
name="Events"
disabled={!recp.recipient}
</TableCell>
<TableCell>
<button
on:click={() => openEventsModal(recp.recipientID)}
/>
class="block w-full py-1 text-left"
>
{recp.recipient.lastName}
</button>
</TableCell>
<TableCell>
{#if recp?.recipient?.email}
<button
on:click={() => openEventsModal(recp.recipientID)}
class="block w-full py-1 text-left"
>
{recp.recipient.email}
</button>
{/if}
</TableCell>
{/if}
<TableCell>
<EventName eventName={recp?.notableEventName} />
</TableCell>
<TableCell value={recp?.sendAt} isDate />
<TableCell value={recp?.sentAt} isDate />
<TableCell value={recp?.cancelledAt} isDate />
{#if !campaign.sentAt}
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableViewButton
name="Events"
disabled={!recp.recipient}
on:click={() => openEventsModal(recp.recipientID)}
/>
<TableDropDownButton
name={recp.sentAt ? `Send message again` : `Send message`}
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
: 'Switch to global view to perform this action'
: recp.closedAt
? 'Campaign is closed'
: recp.cancelledAt
? 'Recipient cancelled'
: recp.sentAt
? `Send message again (last sent: ${new Date(recp.sentAt).toLocaleDateString()})`
: `Send message to recipient`}
on:click={() => showSendMessageModal(recp.id, recp.recipient)}
disabled={!!campaign.closedAt || recp.cancelledAt || isContextMismatch()}
/>
<TableUpdateButton
name="Copy lure URL"
disabled={!!campaign.closedAt ||
!!campaign.anonymizedAt ||
!recp.recipient ||
isContextMismatch()}
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
: 'Switch to global view to perform this action'
: campaign.closedAt
? 'Campaign is closed'
: campaign.anonymizedAt
? 'Campaign is anonymized'
: !recp.recipient
? 'Recipient not available'
: ''}
on:click={() => onClickCopyURL(recp.id)}
/>
<TableUpdateButton
name="Copy email content"
disabled={!!campaign.closedAt || !!campaign.anonymizedAt || isContextMismatch()}
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
: 'Switch to global view to perform this action'
: campaign.closedAt
? 'Campaign is closed'
: campaign.anonymizedAt
? 'Campaign is anonymized'
: ''}
on:click={() => onClickCopyEmailContent(recp.id)}
/>
{#if !campaign.sendStartAt}
<!-- self managed campaign -->
<TableDropDownButton
name="Set as message sent"
name={recp.sentAt ? `Send message again` : `Send message`}
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
: 'Switch to global view to perform this action'
: recp.closedAt
? 'Campaign is closed'
: ''}
on:click={() => onClickSetEmailSent(recp.id, recp.recipient)}
: recp.cancelledAt
? 'Recipient cancelled'
: recp.sentAt
? `Send message again (last sent: ${new Date(recp.sentAt).toLocaleDateString()})`
: `Send message to recipient`}
on:click={() => showSendMessageModal(recp.id, recp.recipient)}
disabled={!!campaign.closedAt || recp.cancelledAt || isContextMismatch()}
/>
{/if}
<TableViewButton
name="View email"
disabled={!!campaign.closedAt ||
!!campaign.anonymizedAt ||
!recp.recipient ||
isContextMismatch()}
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
: 'Switch to global view to perform this action'
: campaign.closedAt
? 'Campaign is closed'
: campaign.anonymizedAt
? 'Campaign is anonymized'
: !recp.recipient
? 'Recipient not available'
<TableUpdateButton
name="Copy lure URL"
disabled={!!campaign.closedAt ||
!!campaign.anonymizedAt ||
!recp.recipient ||
isContextMismatch()}
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
: 'Switch to global view to perform this action'
: campaign.closedAt
? 'Campaign is closed'
: campaign.anonymizedAt
? 'Campaign is anonymized'
: !recp.recipient
? 'Recipient not available'
: ''}
on:click={() => onClickCopyURL(recp.id)}
/>
<TableUpdateButton
name="Copy email content"
disabled={!!campaign.closedAt || !!campaign.anonymizedAt || isContextMismatch()}
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
: 'Switch to global view to perform this action'
: campaign.closedAt
? 'Campaign is closed'
: campaign.anonymizedAt
? 'Campaign is anonymized'
: ''}
on:click={() => onClickPreviewEmail(recp.id)}
/>
</TableDropDownEllipsis>
</TableCellAction>
{/if}
</TableRow>
{/each}
</Table>
on:click={() => onClickCopyEmailContent(recp.id)}
/>
{#if !campaign.sendStartAt}
<!-- self managed campaign -->
<TableDropDownButton
name="Set as message sent"
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
: 'Switch to global view to perform this action'
: recp.closedAt
? 'Campaign is closed'
: ''}
on:click={() => onClickSetEmailSent(recp.id, recp.recipient)}
disabled={!!campaign.closedAt || recp.cancelledAt || isContextMismatch()}
/>
{/if}
<TableViewButton
name="View email"
disabled={!!campaign.closedAt ||
!!campaign.anonymizedAt ||
!recp.recipient ||
isContextMismatch()}
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
: 'Switch to global view to perform this action'
: campaign.closedAt
? 'Campaign is closed'
: campaign.anonymizedAt
? 'Campaign is anonymized'
: !recp.recipient
? 'Recipient not available'
: ''}
on:click={() => onClickPreviewEmail(recp.id)}
/>
</TableDropDownEllipsis>
</TableCellAction>
{/if}
</TableRow>
{/each}
</Table>
{/if}
{/if}
<Modal headerText={'Events'} visible={isEventsModalVisible} onClose={closeEventsModal}>
<div class="mt-8"></div>