feat(automation): dispatch rules/webhooks on mail, drive, contacts
Wire automation dispatcher to IMAP sync, drive mutations, and contact CRUD. Add webhook event_types and mail/drive/contacts scope filters (migration 30).
This commit is contained in:
parent
bd7534658a
commit
082cac36b2
@ -30,6 +30,7 @@ import (
|
|||||||
meetapi "github.com/ultisuite/ulti-backend/internal/api/meet"
|
meetapi "github.com/ultisuite/ulti-backend/internal/api/meet"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||||
photosapi "github.com/ultisuite/ulti-backend/internal/api/photos"
|
photosapi "github.com/ultisuite/ulti-backend/internal/api/photos"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/automation"
|
||||||
"github.com/ultisuite/ulti-backend/internal/authentik"
|
"github.com/ultisuite/ulti-backend/internal/authentik"
|
||||||
"github.com/ultisuite/ulti-backend/internal/auth"
|
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||||
"github.com/ultisuite/ulti-backend/internal/dbmigrate"
|
"github.com/ultisuite/ulti-backend/internal/dbmigrate"
|
||||||
@ -159,7 +160,9 @@ func main() {
|
|||||||
hub := realtime.NewHub(verifierHolder, pool)
|
hub := realtime.NewHub(verifierHolder, pool)
|
||||||
healthChecker := observability.NewHealthChecker(cfg, pool, rdb)
|
healthChecker := observability.NewHealthChecker(cfg, pool, rdb)
|
||||||
|
|
||||||
rulesEngine := rules.NewEngineWithWebhooks(pool, webhooks.NewExecutor(pool))
|
hookExec := webhooks.NewExecutor(pool)
|
||||||
|
rulesEngine := rules.NewEngineWithWebhooks(pool, hookExec)
|
||||||
|
autoDispatcher := automation.NewDispatcher(pool, rulesEngine, hookExec)
|
||||||
|
|
||||||
oauthRedirect := cfg.MailOAuthRedirectURL
|
oauthRedirect := cfg.MailOAuthRedirectURL
|
||||||
if oauthRedirect == "" {
|
if oauthRedirect == "" {
|
||||||
@ -181,7 +184,7 @@ func main() {
|
|||||||
syncWorker := imapsync.NewSyncWorker(pool, cfg.MailSyncInterval, credentialManager, mailOAuthSvc, imapsync.SyncDeps{
|
syncWorker := imapsync.NewSyncWorker(pool, cfg.MailSyncInterval, credentialManager, mailOAuthSvc, imapsync.SyncDeps{
|
||||||
Storage: attachmentStorage,
|
Storage: attachmentStorage,
|
||||||
AttachBucket: cfg.MailAttachmentsBucket,
|
AttachBucket: cfg.MailAttachmentsBucket,
|
||||||
Rules: rulesEngine,
|
Automation: autoDispatcher,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
})
|
})
|
||||||
go syncWorker.Start(ctx)
|
go syncWorker.Start(ctx)
|
||||||
@ -228,10 +231,14 @@ func main() {
|
|||||||
|
|
||||||
var driveHandler *drive.Handler
|
var driveHandler *drive.Handler
|
||||||
var driveSvc *drive.Service
|
var driveSvc *drive.Service
|
||||||
|
var contactsHandler *contacts.Handler
|
||||||
if ncClient != nil {
|
if ncClient != nil {
|
||||||
driveSvc = drive.NewService(ncClient, hub, pool)
|
driveSvc = drive.NewService(ncClient, hub, pool)
|
||||||
|
driveSvc.SetAutomation(autoDispatcher)
|
||||||
driveHandler = drive.NewHandlerWithService(driveSvc)
|
driveHandler = drive.NewHandlerWithService(driveSvc)
|
||||||
mailHandler.SetDriveUploader(&drivebridge.Bridge{Svc: driveSvc})
|
mailHandler.SetDriveUploader(&drivebridge.Bridge{Svc: driveSvc})
|
||||||
|
contactsHandler = contacts.NewHandler(ncClient, pool)
|
||||||
|
contactsHandler.SetAutomation(autoDispatcher)
|
||||||
}
|
}
|
||||||
if ncClient != nil && cfg.OnlyOfficeEnabled && driveSvc != nil {
|
if ncClient != nil && cfg.OnlyOfficeEnabled && driveSvc != nil {
|
||||||
officeSvc := office.NewService(ncClient, office.Config{
|
officeSvc := office.NewService(ncClient, office.Config{
|
||||||
@ -269,7 +276,7 @@ func main() {
|
|||||||
if driveHandler != nil {
|
if driveHandler != nil {
|
||||||
r.Mount("/api/v1/drive", driveHandler.Routes())
|
r.Mount("/api/v1/drive", driveHandler.Routes())
|
||||||
r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg).Routes())
|
r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg).Routes())
|
||||||
r.Mount("/api/v1/contacts", contacts.NewHandler(ncClient, pool).Routes())
|
r.Mount("/api/v1/contacts", contactsHandler.Routes())
|
||||||
}
|
}
|
||||||
if meetCfg != nil {
|
if meetCfg != nil {
|
||||||
r.Mount("/api/v1/meet", meetapi.NewHandler(meetCfg).Routes())
|
r.Mount("/api/v1/meet", meetapi.NewHandler(meetCfg).Routes())
|
||||||
|
|||||||
31
internal/api/contacts/automation_hooks.go
Normal file
31
internal/api/contacts/automation_hooks.go
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
package contacts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/automation"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/mail/rules"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contactAutomation interface {
|
||||||
|
OnContactEvent(ctx context.Context, externalUserID string, trigger rules.TriggerType, payload automation.ContactPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) SetAutomation(d contactAutomation) {
|
||||||
|
h.automation = d
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactPayloadFrom(bookID string, contact *nextcloud.Contact) automation.ContactPayload {
|
||||||
|
if contact == nil {
|
||||||
|
return automation.ContactPayload{BookID: bookID}
|
||||||
|
}
|
||||||
|
return automation.ContactPayload{
|
||||||
|
ID: contact.UID,
|
||||||
|
BookID: bookID,
|
||||||
|
Name: contact.FullName,
|
||||||
|
Email: contact.Email,
|
||||||
|
Phone: contact.Phone,
|
||||||
|
Org: contact.Org,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,15 +16,18 @@ import (
|
|||||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||||
"github.com/ultisuite/ulti-backend/internal/auth"
|
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/automation"
|
||||||
"github.com/ultisuite/ulti-backend/internal/contacts/discovery"
|
"github.com/ultisuite/ulti-backend/internal/contacts/discovery"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/mail/rules"
|
||||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
"github.com/ultisuite/ulti-backend/internal/permission"
|
"github.com/ultisuite/ulti-backend/internal/permission"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
svc *Service
|
svc *Service
|
||||||
discovery *discovery.Service
|
discovery *discovery.Service
|
||||||
logger *slog.Logger
|
automation contactAutomation
|
||||||
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(nc *nextcloud.Client, db *pgxpool.Pool) *Handler {
|
func NewHandler(nc *nextcloud.Client, db *pgxpool.Pool) *Handler {
|
||||||
@ -216,11 +219,15 @@ func (h *Handler) CreateContact(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
created, err := h.svc.CreateContact(r.Context(), ncUser, chi.URLParam(r, "bookID"), &contact)
|
bookID := chi.URLParam(r, "bookID")
|
||||||
|
created, err := h.svc.CreateContact(r.Context(), ncUser, bookID, &contact)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.writeContactServiceError(w, r, "create contact", err)
|
h.writeContactServiceError(w, r, "create contact", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.automation != nil {
|
||||||
|
h.automation.OnContactEvent(r.Context(), claims.Sub, rules.TriggerContactCreated, contactPayloadFrom(bookID, created))
|
||||||
|
}
|
||||||
apiresponse.WriteJSON(w, http.StatusCreated, created)
|
apiresponse.WriteJSON(w, http.StatusCreated, created)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -284,6 +291,10 @@ func (h *Handler) UpdateContact(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.writeContactServiceError(w, r, "update contact", err)
|
h.writeContactServiceError(w, r, "update contact", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.automation != nil {
|
||||||
|
contact.Path = contactPath
|
||||||
|
h.automation.OnContactEvent(r.Context(), claims.Sub, rules.TriggerContactUpdated, contactPayloadFrom("", &contact))
|
||||||
|
}
|
||||||
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"etag": etag})
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"etag": etag})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -380,6 +391,9 @@ func (h *Handler) DeleteContact(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.writeContactServiceError(w, r, "delete contact", err)
|
h.writeContactServiceError(w, r, "delete contact", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.automation != nil {
|
||||||
|
h.automation.OnContactEvent(r.Context(), claims.Sub, rules.TriggerContactDeleted, automation.ContactPayload{ID: contactPath})
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
internal/api/drive/automation_hooks.go
Normal file
40
internal/api/drive/automation_hooks.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package drive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/automation"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/mail/rules"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) SetAutomation(d driveAutomation) {
|
||||||
|
s.automation = d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) afterDriveFileEvent(ctx context.Context, externalUserID string, trigger rules.TriggerType, filePath string, isFolder bool) {
|
||||||
|
normalized := nextcloud.NormalizeClientPath(filePath)
|
||||||
|
s.notifyFileChanged(externalUserID, normalized)
|
||||||
|
if s.automation == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.automation.OnDriveEvent(ctx, externalUserID, trigger, automation.DrivePayloadFromPath(normalized, isFolder))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) afterDriveShareEvent(ctx context.Context, externalUserID string, filePath string) {
|
||||||
|
normalized := nextcloud.NormalizeClientPath(filePath)
|
||||||
|
s.notifyShareUpdated(externalUserID, normalized)
|
||||||
|
if s.automation == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.automation.OnDriveEvent(ctx, externalUserID, rules.TriggerDriveShareUpdated, automation.DrivePayloadFromPath(normalized, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
func renamedPath(oldPath, newName string) string {
|
||||||
|
dir := path.Dir(nextcloud.NormalizeClientPath(oldPath))
|
||||||
|
if dir == "." || dir == "" {
|
||||||
|
dir = "/"
|
||||||
|
}
|
||||||
|
return nextcloud.NormalizeClientPath(path.Join(dir, newName))
|
||||||
|
}
|
||||||
@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||||
"github.com/ultisuite/ulti-backend/internal/auth"
|
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/mail/rules"
|
||||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
"github.com/ultisuite/ulti-backend/internal/permission"
|
"github.com/ultisuite/ulti-backend/internal/permission"
|
||||||
"github.com/ultisuite/ulti-backend/internal/realtime"
|
"github.com/ultisuite/ulti-backend/internal/realtime"
|
||||||
@ -174,7 +175,7 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.svc.notifyFileChanged(claims.Sub, path)
|
h.svc.afterDriveFileEvent(r.Context(), claims.Sub, rules.TriggerDriveFileCreated, path, false)
|
||||||
apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"status": "uploaded", "path": path})
|
apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"status": "uploaded", "path": path})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -245,7 +246,7 @@ func (h *Handler) DeleteFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.svc.notifyFileChanged(claims.Sub, path)
|
h.svc.afterDriveFileEvent(r.Context(), claims.Sub, rules.TriggerDriveFileDeleted, path, false)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,6 +267,7 @@ func (h *Handler) CreateFolder(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.svc.afterDriveFileEvent(r.Context(), claims.Sub, rules.TriggerDriveFileCreated, path, true)
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -293,7 +295,7 @@ func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.svc.notifyFileChanged(claims.Sub, req.Destination)
|
h.svc.afterDriveFileEvent(r.Context(), claims.Sub, rules.TriggerDriveFileMoved, req.Destination, false)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -321,6 +323,7 @@ func (h *Handler) Copy(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.svc.afterDriveFileEvent(r.Context(), claims.Sub, rules.TriggerDriveFileCreated, req.Destination, false)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -348,6 +351,7 @@ func (h *Handler) Rename(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.svc.afterDriveFileEvent(r.Context(), claims.Sub, rules.TriggerDriveFileUpdated, renamedPath(req.Path, req.NewName), false)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -465,7 +469,7 @@ func (h *Handler) CreateShare(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.svc.notifyShareUpdated(claims.Sub, req.Path)
|
h.svc.afterDriveShareEvent(r.Context(), claims.Sub, req.Path)
|
||||||
apiresponse.WriteJSON(w, http.StatusCreated, share)
|
apiresponse.WriteJSON(w, http.StatusCreated, share)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -598,7 +602,7 @@ func (h *Handler) UpdateShare(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.svc.notifyShareUpdated(claims.Sub, share.Path)
|
h.svc.afterDriveShareEvent(r.Context(), claims.Sub, share.Path)
|
||||||
apiresponse.WriteJSON(w, http.StatusOK, share)
|
apiresponse.WriteJSON(w, http.StatusOK, share)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -613,7 +617,7 @@ func (h *Handler) DeleteShare(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.svc.notifyShareUpdated(claims.Sub, "")
|
h.svc.afterDriveShareEvent(r.Context(), claims.Sub, "")
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -635,7 +639,7 @@ func (h *Handler) RestoreTrash(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.svc.notifyFileChanged(claims.Sub, req.Name)
|
h.svc.afterDriveFileEvent(r.Context(), claims.Sub, rules.TriggerDriveFileCreated, req.Name, false)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -691,7 +695,7 @@ func (h *Handler) SetFavorite(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.svc.notifyFileChanged(claims.Sub, req.Path)
|
h.svc.afterDriveFileEvent(r.Context(), claims.Sub, rules.TriggerDriveFileUpdated, req.Path, false)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -715,7 +719,7 @@ func (h *Handler) CreateNewFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDriveError(w, r, err)
|
writeDriveError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.svc.notifyFileChanged(claims.Sub, target)
|
h.svc.afterDriveFileEvent(r.Context(), claims.Sub, rules.TriggerDriveFileCreated, target, false)
|
||||||
apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"path": target})
|
apiresponse.WriteJSON(w, http.StatusCreated, map[string]string{"path": target})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,8 @@ import (
|
|||||||
"github.com/ultisuite/ulti-backend/internal/api/paginate"
|
"github.com/ultisuite/ulti-backend/internal/api/paginate"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/query"
|
"github.com/ultisuite/ulti-backend/internal/api/query"
|
||||||
"github.com/ultisuite/ulti-backend/internal/auth"
|
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/automation"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/mail/rules"
|
||||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
"github.com/ultisuite/ulti-backend/internal/realtime"
|
"github.com/ultisuite/ulti-backend/internal/realtime"
|
||||||
)
|
)
|
||||||
@ -33,10 +35,15 @@ type Service struct {
|
|||||||
nc *nextcloud.Client
|
nc *nextcloud.Client
|
||||||
hub *realtime.Hub
|
hub *realtime.Hub
|
||||||
db *pgxpool.Pool
|
db *pgxpool.Pool
|
||||||
|
automation driveAutomation
|
||||||
maxUploadBytes int64
|
maxUploadBytes int64
|
||||||
quotaReserveByte int64
|
quotaReserveByte int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type driveAutomation interface {
|
||||||
|
OnDriveEvent(ctx context.Context, externalUserID string, trigger rules.TriggerType, payload automation.DrivePayload)
|
||||||
|
}
|
||||||
|
|
||||||
func NewService(nc *nextcloud.Client, hub *realtime.Hub, db *pgxpool.Pool) *Service {
|
func NewService(nc *nextcloud.Client, hub *realtime.Hub, db *pgxpool.Pool) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
nc: nc,
|
nc: nc,
|
||||||
|
|||||||
@ -721,7 +721,8 @@ func (s *Service) ListWebhooks(ctx context.Context, externalID string, params qu
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.db.Query(ctx, `
|
rows, err := s.db.Query(ctx, `
|
||||||
SELECT id, name, url, method, version, is_active FROM webhook_templates
|
SELECT id, name, url, method, version, is_active, body_template, event_types, mail_scope, drive_scope, contacts_scope
|
||||||
|
FROM webhook_templates
|
||||||
WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
|
WHERE user_id = (SELECT id FROM users WHERE external_id = $1)
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
@ -733,14 +734,20 @@ func (s *Service) ListWebhooks(ctx context.Context, externalID string, params qu
|
|||||||
|
|
||||||
webhooks := make([]map[string]any, 0)
|
webhooks := make([]map[string]any, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id, name, url, method string
|
var id, name, url, method, bodyTemplate string
|
||||||
var version int
|
var version int
|
||||||
var isActive bool
|
var isActive bool
|
||||||
if err := rows.Scan(&id, &name, &url, &method, &version, &isActive); err != nil {
|
var eventTypes, mailScope, driveScope, contactsScope []byte
|
||||||
|
if err := rows.Scan(&id, &name, &url, &method, &version, &isActive, &bodyTemplate, &eventTypes, &mailScope, &driveScope, &contactsScope); err != nil {
|
||||||
return WebhooksList{}, err
|
return WebhooksList{}, err
|
||||||
}
|
}
|
||||||
webhooks = append(webhooks, map[string]any{
|
webhooks = append(webhooks, map[string]any{
|
||||||
"id": id, "name": name, "url": url, "method": method, "version": version, "is_active": isActive,
|
"id": id, "name": name, "url": url, "method": method, "version": version, "is_active": isActive,
|
||||||
|
"body_template": bodyTemplate,
|
||||||
|
"event_types": jsonRawOrEmptyArray(eventTypes),
|
||||||
|
"mail_scope": jsonRawOrEmptyObject(mailScope),
|
||||||
|
"drive_scope": jsonRawOrEmptyObject(driveScope),
|
||||||
|
"contacts_scope": jsonRawOrEmptyObject(contactsScope),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
@ -753,15 +760,52 @@ func (s *Service) ListWebhooks(ctx context.Context, externalID string, params qu
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func jsonRawOrEmptyArray(raw []byte) json.RawMessage {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return json.RawMessage("[]")
|
||||||
|
}
|
||||||
|
return json.RawMessage(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonRawOrEmptyObject(raw []byte) json.RawMessage {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return json.RawMessage("{}")
|
||||||
|
}
|
||||||
|
return json.RawMessage(raw)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) CreateWebhook(ctx context.Context, externalID string, req *createWebhookRequest, method string, maxRetries int) (string, error) {
|
func (s *Service) CreateWebhook(ctx context.Context, externalID string, req *createWebhookRequest, method string, maxRetries int) (string, error) {
|
||||||
headersJSON, _ := json.Marshal(req.Headers)
|
headersJSON, _ := json.Marshal(req.Headers)
|
||||||
|
eventTypesJSON, err := marshalWebhookEventTypes(req.EventTypes)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
mailScopeJSON, err := marshalWebhookMailScope(req.MailScope)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
driveScopeJSON, err := marshalWebhookDriveScope(req.DriveScope)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
contactsScopeJSON, err := marshalWebhookContactsScope(req.ContactsScope)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
var id string
|
var id string
|
||||||
err := s.db.QueryRow(ctx, `
|
err = s.db.QueryRow(ctx, `
|
||||||
INSERT INTO webhook_templates (user_id, name, url, method, headers, body_template, version, signing_secret, max_retries)
|
INSERT INTO webhook_templates (
|
||||||
VALUES ((SELECT id FROM users WHERE external_id = $1), $2, $3, $4, $5, $6, 1, $7, $8)
|
user_id, name, url, method, headers, body_template, version, signing_secret, max_retries,
|
||||||
|
event_types, mail_scope, drive_scope, contacts_scope
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
(SELECT id FROM users WHERE external_id = $1), $2, $3, $4, $5, $6, 1, $7, $8,
|
||||||
|
$9, $10, $11, $12
|
||||||
|
)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, externalID, req.Name, req.URL, method, headersJSON, req.BodyTemplate, req.SigningSecret, maxRetries).Scan(&id)
|
`, externalID, req.Name, req.URL, method, headersJSON, req.BodyTemplate, req.SigningSecret, maxRetries,
|
||||||
|
eventTypesJSON, mailScopeJSON, driveScopeJSON, contactsScopeJSON).Scan(&id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,23 @@ func (s *Service) UpdateWebhook(ctx context.Context, externalID, webhookID strin
|
|||||||
defer tx.Rollback(ctx)
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
var version int
|
var version int
|
||||||
|
eventTypesJSON, err := marshalWebhookEventTypes(req.EventTypes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mailScopeJSON, err := marshalWebhookMailScope(req.MailScope)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
driveScopeJSON, err := marshalWebhookDriveScope(req.DriveScope)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
contactsScopeJSON, err := marshalWebhookContactsScope(req.ContactsScope)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
UPDATE webhook_templates
|
UPDATE webhook_templates
|
||||||
SET
|
SET
|
||||||
@ -29,12 +46,17 @@ func (s *Service) UpdateWebhook(ctx context.Context, externalID, webhookID strin
|
|||||||
body_template = $5,
|
body_template = $5,
|
||||||
signing_secret = $6,
|
signing_secret = $6,
|
||||||
max_retries = $7,
|
max_retries = $7,
|
||||||
|
event_types = $8,
|
||||||
|
mail_scope = $9,
|
||||||
|
drive_scope = $10,
|
||||||
|
contacts_scope = $11,
|
||||||
version = version + 1,
|
version = version + 1,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $8
|
WHERE id = $12
|
||||||
AND user_id = (SELECT id FROM users WHERE external_id = $9)
|
AND user_id = (SELECT id FROM users WHERE external_id = $13)
|
||||||
RETURNING version
|
RETURNING version
|
||||||
`, req.Name, req.URL, method, headersJSON, req.BodyTemplate, req.SigningSecret, maxRetries, webhookID, externalID).Scan(&version)
|
`, req.Name, req.URL, method, headersJSON, req.BodyTemplate, req.SigningSecret, maxRetries,
|
||||||
|
eventTypesJSON, mailScopeJSON, driveScopeJSON, contactsScopeJSON, webhookID, externalID).Scan(&version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
|
|||||||
@ -605,6 +605,25 @@ type createWebhookRequest struct {
|
|||||||
BodyTemplate string `json:"body_template"`
|
BodyTemplate string `json:"body_template"`
|
||||||
SigningSecret string `json:"signing_secret"`
|
SigningSecret string `json:"signing_secret"`
|
||||||
MaxRetries *int `json:"max_retries"`
|
MaxRetries *int `json:"max_retries"`
|
||||||
|
EventTypes []string `json:"event_types"`
|
||||||
|
MailScope *webhookMailScope `json:"mail_scope"`
|
||||||
|
DriveScope *webhookDriveScope `json:"drive_scope"`
|
||||||
|
ContactsScope *webhookContactsScope `json:"contacts_scope"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type webhookMailScope struct {
|
||||||
|
AllAccounts bool `json:"all_accounts"`
|
||||||
|
AccountIDs []string `json:"account_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type webhookDriveScope struct {
|
||||||
|
AllFolders bool `json:"all_folders"`
|
||||||
|
FolderPaths []string `json:"folder_paths"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type webhookContactsScope struct {
|
||||||
|
AllBooks bool `json:"all_books"`
|
||||||
|
BookIDs []string `json:"book_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateWebhookRequest struct {
|
type updateWebhookRequest struct {
|
||||||
@ -615,6 +634,10 @@ type updateWebhookRequest struct {
|
|||||||
BodyTemplate string `json:"body_template"`
|
BodyTemplate string `json:"body_template"`
|
||||||
SigningSecret string `json:"signing_secret"`
|
SigningSecret string `json:"signing_secret"`
|
||||||
MaxRetries *int `json:"max_retries"`
|
MaxRetries *int `json:"max_retries"`
|
||||||
|
EventTypes []string `json:"event_types"`
|
||||||
|
MailScope *webhookMailScope `json:"mail_scope"`
|
||||||
|
DriveScope *webhookDriveScope `json:"drive_scope"`
|
||||||
|
ContactsScope *webhookContactsScope `json:"contacts_scope"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type previewWebhookMessageRequest struct {
|
type previewWebhookMessageRequest struct {
|
||||||
|
|||||||
50
internal/api/mail/webhook_scope.go
Normal file
50
internal/api/mail/webhook_scope.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/automation"
|
||||||
|
)
|
||||||
|
|
||||||
|
func marshalWebhookEventTypes(types []string) ([]byte, error) {
|
||||||
|
if types == nil {
|
||||||
|
types = []string{}
|
||||||
|
}
|
||||||
|
return json.Marshal(types)
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalWebhookMailScope(scope *webhookMailScope) ([]byte, error) {
|
||||||
|
out := automation.MailScope{AllAccounts: true}
|
||||||
|
if scope != nil {
|
||||||
|
out.AllAccounts = scope.AllAccounts
|
||||||
|
out.AccountIDs = scope.AccountIDs
|
||||||
|
if out.AccountIDs == nil {
|
||||||
|
out.AccountIDs = []string{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.Marshal(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalWebhookDriveScope(scope *webhookDriveScope) ([]byte, error) {
|
||||||
|
out := automation.DriveScope{AllFolders: true}
|
||||||
|
if scope != nil {
|
||||||
|
out.AllFolders = scope.AllFolders
|
||||||
|
out.FolderPaths = scope.FolderPaths
|
||||||
|
if out.FolderPaths == nil {
|
||||||
|
out.FolderPaths = []string{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.Marshal(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalWebhookContactsScope(scope *webhookContactsScope) ([]byte, error) {
|
||||||
|
out := automation.ContactsScope{AllBooks: true}
|
||||||
|
if scope != nil {
|
||||||
|
out.AllBooks = scope.AllBooks
|
||||||
|
out.BookIDs = scope.BookIDs
|
||||||
|
if out.BookIDs == nil {
|
||||||
|
out.BookIDs = []string{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.Marshal(out)
|
||||||
|
}
|
||||||
250
internal/automation/dispatcher.go
Normal file
250
internal/automation/dispatcher.go
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
package automation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/mail/rules"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/mail/webhooks"
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Dispatcher struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
rules *rules.Engine
|
||||||
|
hooks *webhooks.Executor
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDispatcher(db *pgxpool.Pool, rulesEngine *rules.Engine, hookExec *webhooks.Executor) *Dispatcher {
|
||||||
|
return &Dispatcher{
|
||||||
|
db: db,
|
||||||
|
rules: rulesEngine,
|
||||||
|
hooks: hookExec,
|
||||||
|
logger: slog.Default().With("component", "automation-dispatcher"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DrivePayload struct {
|
||||||
|
FilePath string
|
||||||
|
FileName string
|
||||||
|
MimeType string
|
||||||
|
FileSize int64
|
||||||
|
IsFolder bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContactPayload struct {
|
||||||
|
ID string
|
||||||
|
BookID string
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
Phone string
|
||||||
|
Org string
|
||||||
|
Labels []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func DrivePayloadFromPath(filePath string, isFolder bool) DrivePayload {
|
||||||
|
normalized := nextcloud.NormalizeClientPath(filePath)
|
||||||
|
name := path.Base(strings.TrimSuffix(normalized, "/"))
|
||||||
|
if name == "" || name == "." {
|
||||||
|
name = normalized
|
||||||
|
}
|
||||||
|
return DrivePayload{
|
||||||
|
FilePath: normalized,
|
||||||
|
FileName: name,
|
||||||
|
IsFolder: isFolder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dispatcher) OnMailCreated(ctx context.Context, userID, accountID, messageID string, msg *rules.Message) {
|
||||||
|
if d == nil || userID == "" || msg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
evt := &rules.EventContext{Type: rules.TriggerMessageReceived}
|
||||||
|
if msg.AccountID == "" {
|
||||||
|
msg.AccountID = accountID
|
||||||
|
}
|
||||||
|
if msg.ID == "" {
|
||||||
|
msg.ID = messageID
|
||||||
|
}
|
||||||
|
d.runRules(ctx, userID, msg, evt)
|
||||||
|
d.dispatchWebhooks(ctx, userID, string(rules.TriggerMessageReceived), evt, msg, accountID, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dispatcher) OnDriveEvent(ctx context.Context, externalUserID string, trigger rules.TriggerType, payload DrivePayload) {
|
||||||
|
if d == nil || externalUserID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, err := d.resolveUserID(ctx, externalUserID)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Error("resolve user for drive automation", "error", err, "sub", externalUserID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
evt := driveEventContext(trigger, payload)
|
||||||
|
msg := &rules.Message{}
|
||||||
|
d.runRules(ctx, userID, msg, evt)
|
||||||
|
d.dispatchWebhooks(ctx, userID, string(trigger), evt, msg, "", payload.FilePath, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dispatcher) OnContactEvent(ctx context.Context, externalUserID string, trigger rules.TriggerType, payload ContactPayload) {
|
||||||
|
if d == nil || externalUserID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, err := d.resolveUserID(ctx, externalUserID)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Error("resolve user for contact automation", "error", err, "sub", externalUserID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
evt := contactEventContext(trigger, payload)
|
||||||
|
msg := &rules.Message{}
|
||||||
|
d.runRules(ctx, userID, msg, evt)
|
||||||
|
d.dispatchWebhooks(ctx, userID, string(trigger), evt, msg, "", "", payload.BookID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dispatcher) runRules(ctx context.Context, userID string, msg *rules.Message, evt *rules.EventContext) {
|
||||||
|
if d.rules == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := d.rules.EvaluateMessageEvent(ctx, userID, msg, evt); err != nil {
|
||||||
|
d.logger.Error("rules evaluation", "error", err, "user_id", userID, "trigger", evt.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type webhookTemplateRow struct {
|
||||||
|
id string
|
||||||
|
eventTypes []byte
|
||||||
|
mailScope []byte
|
||||||
|
driveScope []byte
|
||||||
|
contactsScope []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dispatcher) dispatchWebhooks(
|
||||||
|
ctx context.Context,
|
||||||
|
userID string,
|
||||||
|
eventType string,
|
||||||
|
evt *rules.EventContext,
|
||||||
|
msg *rules.Message,
|
||||||
|
accountID string,
|
||||||
|
drivePath string,
|
||||||
|
bookID string,
|
||||||
|
) {
|
||||||
|
if d.hooks == nil || d.db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := d.db.Query(ctx, `
|
||||||
|
SELECT id, event_types, mail_scope, drive_scope, contacts_scope
|
||||||
|
FROM webhook_templates
|
||||||
|
WHERE user_id = $1 AND is_active = true
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Error("list webhook templates", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
msgCtx := rules.WebhookContextFromEvent(evt, msg)
|
||||||
|
for rows.Next() {
|
||||||
|
var row webhookTemplateRow
|
||||||
|
if err := rows.Scan(&row.id, &row.eventTypes, &row.mailScope, &row.driveScope, &row.contactsScope); err != nil {
|
||||||
|
d.logger.Error("scan webhook template", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !webhookMatchesEvent(row, eventType) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !webhookMatchesScope(row, accountID, drivePath, bookID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := d.hooks.Execute(ctx, row.id, msgCtx); err != nil {
|
||||||
|
d.logger.Error("webhook dispatch", "template_id", row.id, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func webhookMatchesEvent(row webhookTemplateRow, eventType string) bool {
|
||||||
|
var types []string
|
||||||
|
if len(row.eventTypes) > 0 {
|
||||||
|
_ = json.Unmarshal(row.eventTypes, &types)
|
||||||
|
}
|
||||||
|
if len(types) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, t := range types {
|
||||||
|
if t == eventType {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func webhookMatchesScope(row webhookTemplateRow, accountID, drivePath, bookID string) bool {
|
||||||
|
var mailScope MailScope
|
||||||
|
var driveScope DriveScope
|
||||||
|
var contactsScope ContactsScope
|
||||||
|
_ = json.Unmarshal(row.mailScope, &mailScope)
|
||||||
|
_ = json.Unmarshal(row.driveScope, &driveScope)
|
||||||
|
_ = json.Unmarshal(row.contactsScope, &contactsScope)
|
||||||
|
|
||||||
|
if accountID != "" {
|
||||||
|
return AllowsMailScope(mailScope, accountID)
|
||||||
|
}
|
||||||
|
if drivePath != "" {
|
||||||
|
return AllowsDriveScope(driveScope, drivePath)
|
||||||
|
}
|
||||||
|
if bookID != "" {
|
||||||
|
return AllowsContactsScope(contactsScope, bookID)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dispatcher) resolveUserID(ctx context.Context, externalID string) (string, error) {
|
||||||
|
var userID string
|
||||||
|
err := d.db.QueryRow(ctx, `SELECT id FROM users WHERE external_id = $1`, externalID).Scan(&userID)
|
||||||
|
return userID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func driveEventContext(trigger rules.TriggerType, p DrivePayload) *rules.EventContext {
|
||||||
|
return &rules.EventContext{
|
||||||
|
Type: trigger,
|
||||||
|
FolderPath: path.Dir(p.FilePath),
|
||||||
|
DriveFileName: p.FileName,
|
||||||
|
DriveFilePath: p.FilePath,
|
||||||
|
DriveMimeType: p.MimeType,
|
||||||
|
DriveFileSize: p.FileSize,
|
||||||
|
DriveIsFolder: p.IsFolder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactEventContext(trigger rules.TriggerType, p ContactPayload) *rules.EventContext {
|
||||||
|
return &rules.EventContext{
|
||||||
|
Type: trigger,
|
||||||
|
ContactLabel: strings.Join(p.Labels, ", "),
|
||||||
|
ContactID: p.ID,
|
||||||
|
ContactName: p.Name,
|
||||||
|
ContactEmail: p.Email,
|
||||||
|
ContactPhone: p.Phone,
|
||||||
|
ContactOrg: p.Org,
|
||||||
|
ContactBookID: p.BookID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func EventDomain(trigger rules.TriggerType) string {
|
||||||
|
switch trigger {
|
||||||
|
case rules.TriggerDriveFileCreated, rules.TriggerDriveFileUpdated, rules.TriggerDriveFileDeleted,
|
||||||
|
rules.TriggerDriveFileMoved, rules.TriggerDriveShareUpdated:
|
||||||
|
return "drive"
|
||||||
|
case rules.TriggerContactCreated, rules.TriggerContactUpdated, rules.TriggerContactDeleted:
|
||||||
|
return "contacts"
|
||||||
|
default:
|
||||||
|
return "mail"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NowISO() string {
|
||||||
|
return time.Now().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
81
internal/automation/scope.go
Normal file
81
internal/automation/scope.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package automation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/apitokens"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MailScope struct {
|
||||||
|
AllAccounts bool `json:"all_accounts"`
|
||||||
|
AccountIDs []string `json:"account_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DriveScope struct {
|
||||||
|
AllFolders bool `json:"all_folders"`
|
||||||
|
FolderPaths []string `json:"folder_paths"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContactsScope struct {
|
||||||
|
AllBooks bool `json:"all_books"`
|
||||||
|
BookIDs []string `json:"book_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func AllowsMailScope(scope MailScope, accountID string) bool {
|
||||||
|
if accountID == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if scope.AllAccounts {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, id := range scope.AccountIDs {
|
||||||
|
if id == accountID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func AllowsDriveScope(scope DriveScope, filePath string) bool {
|
||||||
|
if scope.AllFolders {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
target := apitokens.NormalizeDriveScopePath(filePath)
|
||||||
|
if target == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, allowed := range scope.FolderPaths {
|
||||||
|
if apitokens.NormalizeDriveScopePath(allowed) == "/" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if drivePathWithinScope(target, allowed) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func drivePathWithinScope(target, allowed string) bool {
|
||||||
|
target = apitokens.NormalizeDriveScopePath(target)
|
||||||
|
allowed = apitokens.NormalizeDriveScopePath(allowed)
|
||||||
|
if allowed == "/" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if target == allowed {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return len(target) > len(allowed) && target[:len(allowed)+1] == allowed+"/"
|
||||||
|
}
|
||||||
|
|
||||||
|
func AllowsContactsScope(scope ContactsScope, bookID string) bool {
|
||||||
|
if bookID == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if scope.AllBooks {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, id := range scope.BookIDs {
|
||||||
|
if id == bookID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@ -19,18 +19,20 @@ type postSyncEvent struct {
|
|||||||
|
|
||||||
// syncPipeline runs rules and realtime notifications after message sync.
|
// syncPipeline runs rules and realtime notifications after message sync.
|
||||||
type syncPipeline struct {
|
type syncPipeline struct {
|
||||||
db *pgxpool.Pool
|
db *pgxpool.Pool
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
rules *rules.Engine
|
rules *rules.Engine
|
||||||
hub *realtime.Hub
|
automation MailAutomation
|
||||||
|
hub *realtime.Hub
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSyncPipeline(db *pgxpool.Pool, rulesEngine *rules.Engine, hub *realtime.Hub) *syncPipeline {
|
func newSyncPipeline(db *pgxpool.Pool, rulesEngine *rules.Engine, automation MailAutomation, hub *realtime.Hub) *syncPipeline {
|
||||||
return &syncPipeline{
|
return &syncPipeline{
|
||||||
db: db,
|
db: db,
|
||||||
logger: slog.Default().With("component", "imap-pipeline"),
|
logger: slog.Default().With("component", "imap-pipeline"),
|
||||||
rules: rulesEngine,
|
rules: rulesEngine,
|
||||||
hub: hub,
|
automation: automation,
|
||||||
|
hub: hub,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,12 +42,16 @@ func (p *syncPipeline) handle(ctx context.Context, ev postSyncEvent) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.rules != nil && ev.kind == "created" {
|
if ev.kind == "created" {
|
||||||
msg, err := p.loadRuleMessage(ctx, ev.messageID)
|
msg, err := p.loadRuleMessage(ctx, ev.messageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.logger.Error("load message for rules", "message_id", ev.messageID, "error", err)
|
p.logger.Error("load message for rules", "message_id", ev.messageID, "error", err)
|
||||||
} else if err := p.rules.EvaluateMessage(ctx, ev.userID, msg); err != nil {
|
} else if p.automation != nil {
|
||||||
p.logger.Error("rules evaluation failed", "message_id", ev.messageID, "error", err)
|
p.automation.OnMailCreated(ctx, ev.userID, ev.accountID, ev.messageID, msg)
|
||||||
|
} else if p.rules != nil {
|
||||||
|
if err := p.rules.EvaluateMessage(ctx, ev.userID, msg); err != nil {
|
||||||
|
p.logger.Error("rules evaluation failed", "message_id", ev.messageID, "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -26,11 +26,17 @@ import (
|
|||||||
"github.com/ultisuite/ulti-backend/internal/realtime"
|
"github.com/ultisuite/ulti-backend/internal/realtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MailAutomation runs rules and outbound webhooks after a new message is synced.
|
||||||
|
type MailAutomation interface {
|
||||||
|
OnMailCreated(ctx context.Context, userID, accountID, messageID string, msg *rules.Message)
|
||||||
|
}
|
||||||
|
|
||||||
// SyncDeps optional services wired into the IMAP sync worker.
|
// SyncDeps optional services wired into the IMAP sync worker.
|
||||||
type SyncDeps struct {
|
type SyncDeps struct {
|
||||||
Storage *storage.Client
|
Storage *storage.Client
|
||||||
AttachBucket string
|
AttachBucket string
|
||||||
Rules *rules.Engine
|
Rules *rules.Engine
|
||||||
|
Automation MailAutomation
|
||||||
Hub *realtime.Hub
|
Hub *realtime.Hub
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,7 +60,7 @@ func NewSyncWorker(db *pgxpool.Pool, interval time.Duration, credManager *creden
|
|||||||
oauth: oauthSvc,
|
oauth: oauthSvc,
|
||||||
storage: deps.Storage,
|
storage: deps.Storage,
|
||||||
attachBucket: deps.AttachBucket,
|
attachBucket: deps.AttachBucket,
|
||||||
pipeline: newSyncPipeline(db, deps.Rules, deps.Hub),
|
pipeline: newSyncPipeline(db, deps.Rules, deps.Automation, deps.Hub),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -127,7 +127,7 @@ func (e *Engine) EvaluateMessageEvent(ctx context.Context, userID string, msg *M
|
|||||||
e.logger.Error("workflow missing start", "rule_id", ruleID)
|
e.logger.Error("workflow missing start", "rule_id", ruleID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
execCtx := newExecContext(msg, userID, wf.Variables)
|
execCtx := newExecContext(msg, userID, wf.Variables, evt)
|
||||||
if err := e.walkWorkflow(ctx, userID, msg, wf, startID, execCtx, 0); err != nil {
|
if err := e.walkWorkflow(ctx, userID, msg, wf, startID, execCtx, 0); err != nil {
|
||||||
e.logger.Error("execute workflow", "rule_id", ruleID, "error", err)
|
e.logger.Error("execute workflow", "rule_id", ruleID, "error", err)
|
||||||
}
|
}
|
||||||
@ -137,10 +137,10 @@ func (e *Engine) EvaluateMessageEvent(ctx context.Context, userID string, msg *M
|
|||||||
var actions []Action
|
var actions []Action
|
||||||
json.Unmarshal(condJSON, &conditions)
|
json.Unmarshal(condJSON, &conditions)
|
||||||
json.Unmarshal(actJSON, &actions)
|
json.Unmarshal(actJSON, &actions)
|
||||||
if !matchesAll(conditions, msg) {
|
if !matchesAllEvent(conditions, msg, evt) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
results = e.executeRuleActions(ctx, ruleID, actions, msg)
|
results = e.executeRuleActions(ctx, ruleID, actions, msg, evt)
|
||||||
}
|
}
|
||||||
|
|
||||||
e.logger.Info("rule matched", "rule_id", ruleID, "rule_name", name, "message_id", msg.ID)
|
e.logger.Info("rule matched", "rule_id", ruleID, "rule_name", name, "message_id", msg.ID)
|
||||||
@ -153,10 +153,10 @@ func (e *Engine) EvaluateMessageEvent(ctx context.Context, userID string, msg *M
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) executeRuleActions(ctx context.Context, ruleID string, actions []Action, msg *Message) []ActionResult {
|
func (e *Engine) executeRuleActions(ctx context.Context, ruleID string, actions []Action, msg *Message, evt *EventContext) []ActionResult {
|
||||||
results := make([]ActionResult, 0, len(actions))
|
results := make([]ActionResult, 0, len(actions))
|
||||||
for _, action := range actions {
|
for _, action := range actions {
|
||||||
err := e.executeAction(ctx, action, msg)
|
err := e.executeAction(ctx, action, msg, evt)
|
||||||
results = append(results, actionResultFrom(action, err))
|
results = append(results, actionResultFrom(action, err))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.logger.Error("action failed", "rule_id", ruleID, "action", action.Type, "error", err)
|
e.logger.Error("action failed", "rule_id", ruleID, "action", action.Type, "error", err)
|
||||||
@ -206,17 +206,29 @@ func aggregateActionErrors(results []ActionResult) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func matchesAll(conditions []Condition, msg *Message) bool {
|
func matchesAll(conditions []Condition, msg *Message) bool {
|
||||||
|
return matchesAllEvent(conditions, msg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesAllEvent(conditions []Condition, msg *Message, evt *EventContext) bool {
|
||||||
for _, cond := range conditions {
|
for _, cond := range conditions {
|
||||||
if !matchCondition(cond, msg) {
|
if !matchCondition(cond, msg, evt) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func matchCondition(cond Condition, msg *Message) bool {
|
func matchCondition(cond Condition, msg *Message, evt *EventContext) bool {
|
||||||
if cond.Field == "label" {
|
if cond.Field == "label" || cond.Field == "contact_label" {
|
||||||
has := messageHasLabel(msg, cond.Value)
|
var labels []string
|
||||||
|
if cond.Field == "label" {
|
||||||
|
labels = msg.Labels
|
||||||
|
} else if evt != nil {
|
||||||
|
if evt.ContactLabel != "" {
|
||||||
|
labels = strings.Split(evt.ContactLabel, ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
has := labelListHas(labels, cond.Value)
|
||||||
switch cond.Operator {
|
switch cond.Operator {
|
||||||
case "has":
|
case "has":
|
||||||
return has
|
return has
|
||||||
@ -243,6 +255,46 @@ func matchCondition(cond Condition, msg *Message) bool {
|
|||||||
} else {
|
} else {
|
||||||
fieldValue = "false"
|
fieldValue = "false"
|
||||||
}
|
}
|
||||||
|
case "drive_file_name":
|
||||||
|
if evt != nil {
|
||||||
|
fieldValue = evt.DriveFileName
|
||||||
|
}
|
||||||
|
case "drive_file_path":
|
||||||
|
if evt != nil {
|
||||||
|
fieldValue = evt.DriveFilePath
|
||||||
|
}
|
||||||
|
case "drive_mime_type":
|
||||||
|
if evt != nil {
|
||||||
|
fieldValue = evt.DriveMimeType
|
||||||
|
}
|
||||||
|
case "drive_file_size":
|
||||||
|
if evt != nil {
|
||||||
|
fieldValue = fmt.Sprintf("%d", evt.DriveFileSize)
|
||||||
|
}
|
||||||
|
case "drive_is_folder":
|
||||||
|
if evt != nil {
|
||||||
|
if evt.DriveIsFolder {
|
||||||
|
fieldValue = "true"
|
||||||
|
} else {
|
||||||
|
fieldValue = "false"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "contact_name":
|
||||||
|
if evt != nil {
|
||||||
|
fieldValue = evt.ContactName
|
||||||
|
}
|
||||||
|
case "contact_email":
|
||||||
|
if evt != nil {
|
||||||
|
fieldValue = evt.ContactEmail
|
||||||
|
}
|
||||||
|
case "contact_phone":
|
||||||
|
if evt != nil {
|
||||||
|
fieldValue = evt.ContactPhone
|
||||||
|
}
|
||||||
|
case "contact_org":
|
||||||
|
if evt != nil {
|
||||||
|
fieldValue = evt.ContactOrg
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@ -282,11 +334,15 @@ func matchCondition(cond Condition, msg *Message) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func messageHasLabel(msg *Message, label string) bool {
|
func messageHasLabel(msg *Message, label string) bool {
|
||||||
|
return labelListHas(msg.Labels, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
func labelListHas(labels []string, label string) bool {
|
||||||
labelLower := strings.ToLower(strings.TrimSpace(label))
|
labelLower := strings.ToLower(strings.TrimSpace(label))
|
||||||
if labelLower == "" {
|
if labelLower == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, l := range msg.Labels {
|
for _, l := range labels {
|
||||||
if strings.ToLower(l) == labelLower {
|
if strings.ToLower(l) == labelLower {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -294,37 +350,11 @@ func messageHasLabel(msg *Message, label string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func messageToWebhookContext(msg *Message) *webhooks.MessageContext {
|
func messageToWebhookContext(msg *Message, evt *EventContext) *webhooks.MessageContext {
|
||||||
senderName, senderEmail := parseFromAddress(msg.From)
|
return WebhookContextFromEvent(evt, msg)
|
||||||
return &webhooks.MessageContext{
|
|
||||||
SenderName: senderName,
|
|
||||||
SenderEmail: senderEmail,
|
|
||||||
Subject: msg.Subject,
|
|
||||||
BodyText: msg.BodyText,
|
|
||||||
Recipients: strings.Join(msg.To, ", "),
|
|
||||||
HasAttachment: msg.HasAttachments,
|
|
||||||
MessageID: msg.ID,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseFromAddress(from string) (name, email string) {
|
func (e *Engine) executeAction(ctx context.Context, action Action, msg *Message, evt *EventContext) error {
|
||||||
from = strings.TrimSpace(from)
|
|
||||||
if from == "" {
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
if i := strings.LastIndex(from, "<"); i >= 0 {
|
|
||||||
j := strings.LastIndex(from, ">")
|
|
||||||
if j > i {
|
|
||||||
email = strings.TrimSpace(from[i+1 : j])
|
|
||||||
name = strings.TrimSpace(from[:i])
|
|
||||||
name = strings.Trim(name, `"`)
|
|
||||||
return name, email
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", from
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) executeAction(ctx context.Context, action Action, msg *Message) error {
|
|
||||||
switch action.Type {
|
switch action.Type {
|
||||||
case "label":
|
case "label":
|
||||||
_, err := e.db.Exec(ctx, `
|
_, err := e.db.Exec(ctx, `
|
||||||
@ -396,7 +426,13 @@ func (e *Engine) executeAction(ctx context.Context, action Action, msg *Message)
|
|||||||
if e.webhookExec == nil {
|
if e.webhookExec == nil {
|
||||||
return fmt.Errorf("webhook executor not configured")
|
return fmt.Errorf("webhook executor not configured")
|
||||||
}
|
}
|
||||||
return e.webhookExec.Execute(ctx, action.Value, messageToWebhookContext(msg))
|
return e.webhookExec.Execute(ctx, action.Value, messageToWebhookContext(msg, evt))
|
||||||
|
case "drive_move", "drive_rename", "drive_delete", "drive_share", "drive_copy":
|
||||||
|
e.logger.Info("deferred drive action", "type", action.Type, "value", action.Value)
|
||||||
|
return nil
|
||||||
|
case "contact_add_label", "contact_remove_label", "contact_delete":
|
||||||
|
e.logger.Info("deferred contact action", "type", action.Type, "value", action.Value)
|
||||||
|
return nil
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown action type: %s", action.Type)
|
return fmt.Errorf("unknown action type: %s", action.Type)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,7 +48,7 @@ func TestMatchCondition_fieldsAndOperators(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if got := matchCondition(tt.cond, msg); got != tt.match {
|
if got := matchCondition(tt.cond, msg, nil); got != tt.match {
|
||||||
t.Fatalf("matchCondition() = %v, want %v", got, tt.match)
|
t.Fatalf("matchCondition() = %v, want %v", got, tt.match)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -87,7 +87,7 @@ func TestMatchesAll(t *testing.T) {
|
|||||||
|
|
||||||
func TestExecuteAction_unknownType(t *testing.T) {
|
func TestExecuteAction_unknownType(t *testing.T) {
|
||||||
e := &Engine{}
|
e := &Engine{}
|
||||||
err := e.executeAction(context.Background(), Action{Type: "unknown_action", Value: "x@example.com"}, &Message{ID: "msg-1"})
|
err := e.executeAction(context.Background(), Action{Type: "unknown_action", Value: "x@example.com"}, &Message{ID: "msg-1"}, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("executeAction() error = nil, want unknown action type error")
|
t.Fatal("executeAction() error = nil, want unknown action type error")
|
||||||
}
|
}
|
||||||
@ -168,7 +168,7 @@ func TestParseFromAddress(t *testing.T) {
|
|||||||
|
|
||||||
func TestMessageToWebhookContext(t *testing.T) {
|
func TestMessageToWebhookContext(t *testing.T) {
|
||||||
msg := testMessage()
|
msg := testMessage()
|
||||||
ctx := messageToWebhookContext(msg)
|
ctx := messageToWebhookContext(msg, nil)
|
||||||
if ctx.SenderName != "Alice" || ctx.SenderEmail != "alice@example.com" {
|
if ctx.SenderName != "Alice" || ctx.SenderEmail != "alice@example.com" {
|
||||||
t.Fatalf("sender = (%q, %q), want (Alice, alice@example.com)", ctx.SenderName, ctx.SenderEmail)
|
t.Fatalf("sender = (%q, %q), want (Alice, alice@example.com)", ctx.SenderName, ctx.SenderEmail)
|
||||||
}
|
}
|
||||||
@ -200,7 +200,7 @@ func TestExecuteAction_webhook(t *testing.T) {
|
|||||||
mock := &mockWebhookExecutor{}
|
mock := &mockWebhookExecutor{}
|
||||||
e := &Engine{webhookExec: mock}
|
e := &Engine{webhookExec: mock}
|
||||||
|
|
||||||
if err := e.executeAction(context.Background(), Action{Type: "webhook", Value: "tpl-abc"}, msg); err != nil {
|
if err := e.executeAction(context.Background(), Action{Type: "webhook", Value: "tpl-abc"}, msg, nil); err != nil {
|
||||||
t.Fatalf("executeAction() error = %v", err)
|
t.Fatalf("executeAction() error = %v", err)
|
||||||
}
|
}
|
||||||
if mock.templateID != "tpl-abc" {
|
if mock.templateID != "tpl-abc" {
|
||||||
@ -211,7 +211,7 @@ func TestExecuteAction_webhook(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
e.webhookExec = nil
|
e.webhookExec = nil
|
||||||
err := e.executeAction(context.Background(), Action{Type: "webhook", Value: "tpl-abc"}, msg)
|
err := e.executeAction(context.Background(), Action{Type: "webhook", Value: "tpl-abc"}, msg, nil)
|
||||||
if err == nil || !strings.Contains(err.Error(), "webhook executor not configured") {
|
if err == nil || !strings.Contains(err.Error(), "webhook executor not configured") {
|
||||||
t.Fatalf("executeAction() without executor = %v, want not configured error", err)
|
t.Fatalf("executeAction() without executor = %v, want not configured error", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,7 @@ func (e *Engine) simulateActions(ctx context.Context, actions []Action, msg *Mes
|
|||||||
|
|
||||||
func (e *Engine) simulateAction(ctx context.Context, action Action, msg *Message) SimulatedActionResult {
|
func (e *Engine) simulateAction(ctx context.Context, action Action, msg *Message) SimulatedActionResult {
|
||||||
switch action.Type {
|
switch action.Type {
|
||||||
case "label", "move", "archive", "delete", "mark_read", "remove_label", "mark_important", "mark_spam", "star", "notify", "reply", "send_mail", "forward":
|
case "label", "move", "archive", "delete", "mark_read", "remove_label", "mark_important", "mark_spam", "star", "notify", "reply", "send_mail", "forward", "drive_move", "drive_rename", "drive_delete", "drive_share", "drive_copy", "contact_add_label", "contact_remove_label", "contact_delete":
|
||||||
return SimulatedActionResult{
|
return SimulatedActionResult{
|
||||||
ActionResult: ActionResult{Type: action.Type, Value: action.Value, OK: true},
|
ActionResult: ActionResult{Type: action.Type, Value: action.Value, OK: true},
|
||||||
}
|
}
|
||||||
@ -58,7 +58,7 @@ func (e *Engine) simulateAction(ctx context.Context, action Action, msg *Message
|
|||||||
ActionResult: actionResultFrom(action, fmt.Errorf("query template: %w", err)),
|
ActionResult: actionResultFrom(action, fmt.Errorf("query template: %w", err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
payload := webhooks.RenderBodyTemplate(bodyTemplate, messageToWebhookContext(msg))
|
payload := webhooks.RenderBodyTemplate(bodyTemplate, messageToWebhookContext(msg, nil))
|
||||||
return SimulatedActionResult{
|
return SimulatedActionResult{
|
||||||
ActionResult: ActionResult{Type: action.Type, Value: action.Value, OK: true},
|
ActionResult: ActionResult{Type: action.Type, Value: action.Value, OK: true},
|
||||||
SimulatedPayload: payload,
|
SimulatedPayload: payload,
|
||||||
|
|||||||
73
internal/mail/rules/webhook_context.go
Normal file
73
internal/mail/rules/webhook_context.go
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ultisuite/ulti-backend/internal/mail/webhooks"
|
||||||
|
)
|
||||||
|
|
||||||
|
func WebhookContextFromEvent(evt *EventContext, msg *Message) *webhooks.MessageContext {
|
||||||
|
ctx := &webhooks.MessageContext{Date: time.Now().UTC().Format(time.RFC3339)}
|
||||||
|
if msg != nil {
|
||||||
|
senderName, senderEmail := parseFromAddress(msg.From)
|
||||||
|
ctx.SenderName = senderName
|
||||||
|
ctx.SenderEmail = senderEmail
|
||||||
|
ctx.Subject = msg.Subject
|
||||||
|
ctx.BodyText = msg.BodyText
|
||||||
|
ctx.Recipients = strings.Join(msg.To, ", ")
|
||||||
|
ctx.HasAttachment = msg.HasAttachments
|
||||||
|
ctx.MessageID = msg.ID
|
||||||
|
}
|
||||||
|
if evt == nil {
|
||||||
|
ctx.EventDomain = "mail"
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
ctx.EventType = string(evt.Type)
|
||||||
|
ctx.EventDomain = webhookEventDomain(evt.Type)
|
||||||
|
ctx.DriveFileName = evt.DriveFileName
|
||||||
|
ctx.DriveFilePath = evt.DriveFilePath
|
||||||
|
ctx.DriveMimeType = evt.DriveMimeType
|
||||||
|
ctx.DriveFileSize = strconv.FormatInt(evt.DriveFileSize, 10)
|
||||||
|
if evt.DriveIsFolder {
|
||||||
|
ctx.DriveIsFolder = "true"
|
||||||
|
} else {
|
||||||
|
ctx.DriveIsFolder = "false"
|
||||||
|
}
|
||||||
|
ctx.ContactID = evt.ContactID
|
||||||
|
ctx.ContactName = evt.ContactName
|
||||||
|
ctx.ContactEmail = evt.ContactEmail
|
||||||
|
ctx.ContactPhone = evt.ContactPhone
|
||||||
|
ctx.ContactOrg = evt.ContactOrg
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func webhookEventDomain(trigger TriggerType) string {
|
||||||
|
switch trigger {
|
||||||
|
case TriggerDriveFileCreated, TriggerDriveFileUpdated, TriggerDriveFileDeleted,
|
||||||
|
TriggerDriveFileMoved, TriggerDriveShareUpdated:
|
||||||
|
return "drive"
|
||||||
|
case TriggerContactCreated, TriggerContactUpdated, TriggerContactDeleted:
|
||||||
|
return "contacts"
|
||||||
|
default:
|
||||||
|
return "mail"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFromAddress(from string) (name, email string) {
|
||||||
|
from = strings.TrimSpace(from)
|
||||||
|
if from == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
if i := strings.LastIndex(from, "<"); i >= 0 {
|
||||||
|
j := strings.LastIndex(from, ">")
|
||||||
|
if j > i {
|
||||||
|
email = strings.TrimSpace(from[i+1 : j])
|
||||||
|
name = strings.TrimSpace(from[:i])
|
||||||
|
name = strings.Trim(name, `"`)
|
||||||
|
return name, email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", from
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ package rules
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const WorkflowVersion = 1
|
const WorkflowVersion = 1
|
||||||
@ -17,16 +18,26 @@ const (
|
|||||||
type TriggerType string
|
type TriggerType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
TriggerMessageReceived TriggerType = "message_received"
|
TriggerMessageReceived TriggerType = "message_received"
|
||||||
TriggerLabelAdded TriggerType = "label_added"
|
TriggerLabelAdded TriggerType = "label_added"
|
||||||
TriggerLabelRemoved TriggerType = "label_removed"
|
TriggerLabelRemoved TriggerType = "label_removed"
|
||||||
|
TriggerDriveFileCreated TriggerType = "drive_file_created"
|
||||||
|
TriggerDriveFileUpdated TriggerType = "drive_file_updated"
|
||||||
|
TriggerDriveFileDeleted TriggerType = "drive_file_deleted"
|
||||||
|
TriggerDriveFileMoved TriggerType = "drive_file_moved"
|
||||||
|
TriggerDriveShareUpdated TriggerType = "drive_share_updated"
|
||||||
|
TriggerContactCreated TriggerType = "contact_created"
|
||||||
|
TriggerContactUpdated TriggerType = "contact_updated"
|
||||||
|
TriggerContactDeleted TriggerType = "contact_deleted"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Trigger struct {
|
type Trigger struct {
|
||||||
Type TriggerType `json:"type"`
|
Type TriggerType `json:"type"`
|
||||||
FolderID string `json:"folder_id,omitempty"`
|
FolderID string `json:"folder_id,omitempty"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
AccountID string `json:"account_id,omitempty"`
|
AccountID string `json:"account_id,omitempty"`
|
||||||
|
FolderPath string `json:"folder_path,omitempty"`
|
||||||
|
ContactLabel string `json:"contact_label,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TriggerGroup struct {
|
type TriggerGroup struct {
|
||||||
@ -114,9 +125,24 @@ type CallRuleNodeData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EventContext struct {
|
type EventContext struct {
|
||||||
Type TriggerType
|
Type TriggerType
|
||||||
FolderID string
|
FolderID string
|
||||||
Label string
|
Label string
|
||||||
|
FolderPath string
|
||||||
|
ContactLabel string
|
||||||
|
// Drive payload (when domain is drive)
|
||||||
|
DriveFileName string
|
||||||
|
DriveFilePath string
|
||||||
|
DriveMimeType string
|
||||||
|
DriveFileSize int64
|
||||||
|
DriveIsFolder bool
|
||||||
|
// Contact payload (when domain is contacts)
|
||||||
|
ContactID string
|
||||||
|
ContactBookID string
|
||||||
|
ContactName string
|
||||||
|
ContactEmail string
|
||||||
|
ContactPhone string
|
||||||
|
ContactOrg string
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseWorkflow(raw []byte) (*Workflow, error) {
|
func ParseWorkflow(raw []byte) (*Workflow, error) {
|
||||||
@ -234,6 +260,22 @@ func matchTrigger(t Trigger, msg *Message, evt *EventContext) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
case TriggerDriveFileCreated, TriggerDriveFileUpdated, TriggerDriveFileDeleted, TriggerDriveFileMoved, TriggerDriveShareUpdated:
|
||||||
|
if evt == nil || evt.Type != t.Type {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if t.FolderPath != "" && evt.FolderPath != "" && !strings.HasPrefix(evt.DriveFilePath, t.FolderPath) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case TriggerContactCreated, TriggerContactUpdated, TriggerContactDeleted:
|
||||||
|
if evt == nil || evt.Type != t.Type {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if t.ContactLabel != "" && evt.ContactLabel != "" && t.ContactLabel != evt.ContactLabel {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,11 +10,12 @@ import (
|
|||||||
type ExecContext struct {
|
type ExecContext struct {
|
||||||
Variables map[string]string
|
Variables map[string]string
|
||||||
Message *Message
|
Message *Message
|
||||||
|
Event *EventContext
|
||||||
UserID string
|
UserID string
|
||||||
Results []ActionResult
|
Results []ActionResult
|
||||||
}
|
}
|
||||||
|
|
||||||
func newExecContext(msg *Message, userID string, vars []ExecVariable) *ExecContext {
|
func newExecContext(msg *Message, userID string, vars []ExecVariable, evt *EventContext) *ExecContext {
|
||||||
m := make(map[string]string, len(vars))
|
m := make(map[string]string, len(vars))
|
||||||
for _, v := range vars {
|
for _, v := range vars {
|
||||||
m[v.Name] = v.Default
|
m[v.Name] = v.Default
|
||||||
@ -22,6 +23,7 @@ func newExecContext(msg *Message, userID string, vars []ExecVariable) *ExecConte
|
|||||||
return &ExecContext{
|
return &ExecContext{
|
||||||
Variables: m,
|
Variables: m,
|
||||||
Message: msg,
|
Message: msg,
|
||||||
|
Event: evt,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Results: make([]ActionResult, 0),
|
Results: make([]ActionResult, 0),
|
||||||
}
|
}
|
||||||
@ -32,7 +34,7 @@ func (e *Engine) ExecuteWorkflow(ctx context.Context, userID string, msg *Messag
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if wf.Kind == RuleKindFunction {
|
if wf.Kind == RuleKindFunction {
|
||||||
return e.runWorkflowGraph(ctx, userID, msg, wf, newExecContext(msg, userID, wf.Variables))
|
return e.runWorkflowGraph(ctx, userID, msg, wf, newExecContext(msg, userID, wf.Variables, evt))
|
||||||
}
|
}
|
||||||
if !matchesTriggers(wf.Triggers, msg, evt) {
|
if !matchesTriggers(wf.Triggers, msg, evt) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@ -41,7 +43,7 @@ func (e *Engine) ExecuteWorkflow(ctx context.Context, userID string, msg *Messag
|
|||||||
if startID == "" {
|
if startID == "" {
|
||||||
return nil, fmt.Errorf("workflow missing start node")
|
return nil, fmt.Errorf("workflow missing start node")
|
||||||
}
|
}
|
||||||
execCtx := newExecContext(msg, userID, wf.Variables)
|
execCtx := newExecContext(msg, userID, wf.Variables, evt)
|
||||||
if err := e.walkWorkflow(ctx, userID, msg, wf, startID, execCtx, 0); err != nil {
|
if err := e.walkWorkflow(ctx, userID, msg, wf, startID, execCtx, 0); err != nil {
|
||||||
return execCtx.Results, err
|
return execCtx.Results, err
|
||||||
}
|
}
|
||||||
@ -78,7 +80,7 @@ func (e *Engine) walkWorkflow(ctx context.Context, userID string, msg *Message,
|
|||||||
cond.Operator = "not_has"
|
cond.Operator = "not_has"
|
||||||
}
|
}
|
||||||
handle := "false"
|
handle := "false"
|
||||||
if matchCondition(cond, msg) {
|
if matchCondition(cond, msg, execCtx.Event) {
|
||||||
handle = "true"
|
handle = "true"
|
||||||
}
|
}
|
||||||
return e.walkWorkflow(ctx, userID, msg, wf, wf.nextNode(nodeID, handle), execCtx, depth+1)
|
return e.walkWorkflow(ctx, userID, msg, wf, wf.nextNode(nodeID, handle), execCtx, depth+1)
|
||||||
@ -90,7 +92,7 @@ func (e *Engine) walkWorkflow(ctx context.Context, userID string, msg *Message,
|
|||||||
}
|
}
|
||||||
cond := Condition{Field: data.Field, Operator: data.Operator, Value: interpolateValue(data.Value, execCtx)}
|
cond := Condition{Field: data.Field, Operator: data.Operator, Value: interpolateValue(data.Value, execCtx)}
|
||||||
handle := "false"
|
handle := "false"
|
||||||
if matchCondition(cond, msg) {
|
if matchCondition(cond, msg, execCtx.Event) {
|
||||||
handle = "true"
|
handle = "true"
|
||||||
}
|
}
|
||||||
return e.walkWorkflow(ctx, userID, msg, wf, wf.nextNode(nodeID, handle), execCtx, depth+1)
|
return e.walkWorkflow(ctx, userID, msg, wf, wf.nextNode(nodeID, handle), execCtx, depth+1)
|
||||||
@ -100,7 +102,7 @@ func (e *Engine) walkWorkflow(ctx context.Context, userID string, msg *Message,
|
|||||||
if err := json.Unmarshal(node.Data, &data); err != nil {
|
if err := json.Unmarshal(node.Data, &data); err != nil {
|
||||||
return fmt.Errorf("switch node data: %w", err)
|
return fmt.Errorf("switch node data: %w", err)
|
||||||
}
|
}
|
||||||
fieldVal := workflowFieldValue(data.Field, msg, execCtx)
|
fieldVal := workflowFieldValue(data.Field, msg, execCtx.Event, execCtx)
|
||||||
handle := "default"
|
handle := "default"
|
||||||
for i, c := range data.Cases {
|
for i, c := range data.Cases {
|
||||||
if strings.EqualFold(fieldVal, c.Value) {
|
if strings.EqualFold(fieldVal, c.Value) {
|
||||||
@ -132,7 +134,7 @@ func (e *Engine) walkWorkflow(ctx context.Context, userID string, msg *Message,
|
|||||||
}
|
}
|
||||||
for _, item := range data.Actions {
|
for _, item := range data.Actions {
|
||||||
action := Action{Type: item.Type, Value: interpolateValue(item.Value, execCtx)}
|
action := Action{Type: item.Type, Value: interpolateValue(item.Value, execCtx)}
|
||||||
err := e.executeAction(ctx, action, msg)
|
err := e.executeAction(ctx, action, msg, execCtx.Event)
|
||||||
result := actionResultFrom(action, err)
|
result := actionResultFrom(action, err)
|
||||||
execCtx.Results = append(execCtx.Results, result)
|
execCtx.Results = append(execCtx.Results, result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -199,6 +201,7 @@ func (e *Engine) invokeSubWorkflow(ctx context.Context, userID string, msg *Mess
|
|||||||
childCtx := &ExecContext{
|
childCtx := &ExecContext{
|
||||||
Variables: copyVars(parent.Variables),
|
Variables: copyVars(parent.Variables),
|
||||||
Message: msg,
|
Message: msg,
|
||||||
|
Event: parent.Event,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Results: parent.Results,
|
Results: parent.Results,
|
||||||
}
|
}
|
||||||
@ -217,7 +220,7 @@ func copyVars(src map[string]string) map[string]string {
|
|||||||
return dst
|
return dst
|
||||||
}
|
}
|
||||||
|
|
||||||
func workflowFieldValue(field string, msg *Message, execCtx *ExecContext) string {
|
func workflowFieldValue(field string, msg *Message, evt *EventContext, execCtx *ExecContext) string {
|
||||||
if strings.HasPrefix(field, "$") {
|
if strings.HasPrefix(field, "$") {
|
||||||
name := strings.TrimPrefix(field, "$")
|
name := strings.TrimPrefix(field, "$")
|
||||||
if v, ok := execCtx.Variables[name]; ok {
|
if v, ok := execCtx.Variables[name]; ok {
|
||||||
@ -242,7 +245,36 @@ func workflowFieldValue(field string, msg *Message, execCtx *ExecContext) string
|
|||||||
case "label":
|
case "label":
|
||||||
return strings.Join(msg.Labels, ", ")
|
return strings.Join(msg.Labels, ", ")
|
||||||
default:
|
default:
|
||||||
return ""
|
if evt == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch field {
|
||||||
|
case "drive_file_name":
|
||||||
|
return evt.DriveFileName
|
||||||
|
case "drive_file_path":
|
||||||
|
return evt.DriveFilePath
|
||||||
|
case "drive_mime_type":
|
||||||
|
return evt.DriveMimeType
|
||||||
|
case "drive_file_size":
|
||||||
|
return fmt.Sprintf("%d", evt.DriveFileSize)
|
||||||
|
case "drive_is_folder":
|
||||||
|
if evt.DriveIsFolder {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
case "contact_name":
|
||||||
|
return evt.ContactName
|
||||||
|
case "contact_email":
|
||||||
|
return evt.ContactEmail
|
||||||
|
case "contact_phone":
|
||||||
|
return evt.ContactPhone
|
||||||
|
case "contact_org":
|
||||||
|
return evt.ContactOrg
|
||||||
|
case "contact_label":
|
||||||
|
return evt.ContactLabel
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -29,7 +29,7 @@ func (e *Engine) SimulateWorkflow(ctx context.Context, userID string, wf *Workfl
|
|||||||
if startID == "" {
|
if startID == "" {
|
||||||
return WorkflowSimulationResult{Matched: false}
|
return WorkflowSimulationResult{Matched: false}
|
||||||
}
|
}
|
||||||
execCtx := newExecContext(msg, userID, wf.Variables)
|
execCtx := newExecContext(msg, userID, wf.Variables, evt)
|
||||||
steps := make([]WorkflowSimulationStep, 0)
|
steps := make([]WorkflowSimulationStep, 0)
|
||||||
e.simulateWalk(ctx, userID, msg, wf, startID, execCtx, &steps, 0)
|
e.simulateWalk(ctx, userID, msg, wf, startID, execCtx, &steps, 0)
|
||||||
simActions := make([]SimulatedActionResult, 0, len(execCtx.Results))
|
simActions := make([]SimulatedActionResult, 0, len(execCtx.Results))
|
||||||
@ -62,7 +62,7 @@ func (e *Engine) simulateWalk(ctx context.Context, userID string, msg *Message,
|
|||||||
var data ConditionNodeData
|
var data ConditionNodeData
|
||||||
json.Unmarshal(node.Data, &data)
|
json.Unmarshal(node.Data, &data)
|
||||||
handle := "false"
|
handle := "false"
|
||||||
if matchCondition(Condition{Field: data.Field, Operator: data.Operator, Value: interpolateValue(data.Value, execCtx)}, msg) {
|
if matchCondition(Condition{Field: data.Field, Operator: data.Operator, Value: interpolateValue(data.Value, execCtx)}, msg, execCtx.Event) {
|
||||||
handle = "true"
|
handle = "true"
|
||||||
}
|
}
|
||||||
*steps = append(*steps, WorkflowSimulationStep{NodeID: nodeID, NodeType: node.Type, Handle: handle})
|
*steps = append(*steps, WorkflowSimulationStep{NodeID: nodeID, NodeType: node.Type, Handle: handle})
|
||||||
@ -76,7 +76,7 @@ func (e *Engine) simulateWalk(ctx context.Context, userID string, msg *Message,
|
|||||||
op = "not_has"
|
op = "not_has"
|
||||||
}
|
}
|
||||||
handle := "false"
|
handle := "false"
|
||||||
if matchCondition(Condition{Field: "label", Operator: op, Value: data.Label}, msg) {
|
if matchCondition(Condition{Field: "label", Operator: op, Value: data.Label}, msg, execCtx.Event) {
|
||||||
handle = "true"
|
handle = "true"
|
||||||
}
|
}
|
||||||
*steps = append(*steps, WorkflowSimulationStep{NodeID: nodeID, NodeType: node.Type, Handle: handle})
|
*steps = append(*steps, WorkflowSimulationStep{NodeID: nodeID, NodeType: node.Type, Handle: handle})
|
||||||
@ -85,7 +85,7 @@ func (e *Engine) simulateWalk(ctx context.Context, userID string, msg *Message,
|
|||||||
case "switch":
|
case "switch":
|
||||||
var data SwitchNodeData
|
var data SwitchNodeData
|
||||||
json.Unmarshal(node.Data, &data)
|
json.Unmarshal(node.Data, &data)
|
||||||
fieldVal := workflowFieldValue(data.Field, msg, execCtx)
|
fieldVal := workflowFieldValue(data.Field, msg, execCtx.Event, execCtx)
|
||||||
handle := "default"
|
handle := "default"
|
||||||
for i, c := range data.Cases {
|
for i, c := range data.Cases {
|
||||||
if fieldVal == c.Value {
|
if fieldVal == c.Value {
|
||||||
|
|||||||
@ -45,6 +45,18 @@ type MessageContext struct {
|
|||||||
Recipients string `json:"recipients"`
|
Recipients string `json:"recipients"`
|
||||||
HasAttachment bool `json:"has_attachment"`
|
HasAttachment bool `json:"has_attachment"`
|
||||||
MessageID string `json:"message_id"`
|
MessageID string `json:"message_id"`
|
||||||
|
EventType string `json:"event_type,omitempty"`
|
||||||
|
EventDomain string `json:"event_domain,omitempty"`
|
||||||
|
DriveFileName string `json:"drive_file_name,omitempty"`
|
||||||
|
DriveFilePath string `json:"drive_file_path,omitempty"`
|
||||||
|
DriveMimeType string `json:"drive_mime_type,omitempty"`
|
||||||
|
DriveFileSize string `json:"drive_file_size,omitempty"`
|
||||||
|
DriveIsFolder string `json:"drive_is_folder,omitempty"`
|
||||||
|
ContactID string `json:"contact_id,omitempty"`
|
||||||
|
ContactName string `json:"contact_name,omitempty"`
|
||||||
|
ContactEmail string `json:"contact_email,omitempty"`
|
||||||
|
ContactPhone string `json:"contact_phone,omitempty"`
|
||||||
|
ContactOrg string `json:"contact_org,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -276,6 +288,18 @@ func interpolate(template string, ctx *MessageContext) string {
|
|||||||
"$date", ctx.Date,
|
"$date", ctx.Date,
|
||||||
"$recipients.to", ctx.Recipients,
|
"$recipients.to", ctx.Recipients,
|
||||||
"$message_id", ctx.MessageID,
|
"$message_id", ctx.MessageID,
|
||||||
|
"$event.type", ctx.EventType,
|
||||||
|
"$event.domain", ctx.EventDomain,
|
||||||
|
"$drive.file_name", ctx.DriveFileName,
|
||||||
|
"$drive.file_path", ctx.DriveFilePath,
|
||||||
|
"$drive.mime_type", ctx.DriveMimeType,
|
||||||
|
"$drive.file_size", ctx.DriveFileSize,
|
||||||
|
"$drive.is_folder", ctx.DriveIsFolder,
|
||||||
|
"$contact.id", ctx.ContactID,
|
||||||
|
"$contact.name", ctx.ContactName,
|
||||||
|
"$contact.email", ctx.ContactEmail,
|
||||||
|
"$contact.phone", ctx.ContactPhone,
|
||||||
|
"$contact.org", ctx.ContactOrg,
|
||||||
)
|
)
|
||||||
return r.Replace(template)
|
return r.Replace(template)
|
||||||
}
|
}
|
||||||
|
|||||||
5
migrations/000030_webhook_automation_scope.down.sql
Normal file
5
migrations/000030_webhook_automation_scope.down.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE webhook_templates
|
||||||
|
DROP COLUMN IF EXISTS event_types,
|
||||||
|
DROP COLUMN IF EXISTS mail_scope,
|
||||||
|
DROP COLUMN IF EXISTS drive_scope,
|
||||||
|
DROP COLUMN IF EXISTS contacts_scope;
|
||||||
5
migrations/000030_webhook_automation_scope.up.sql
Normal file
5
migrations/000030_webhook_automation_scope.up.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE webhook_templates
|
||||||
|
ADD COLUMN IF NOT EXISTS event_types JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS mail_scope JSONB NOT NULL DEFAULT '{"all_accounts":true,"account_ids":[]}'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS drive_scope JSONB NOT NULL DEFAULT '{"all_folders":true,"folder_paths":[]}'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS contacts_scope JSONB NOT NULL DEFAULT '{"all_books":true,"book_ids":[]}'::jsonb;
|
||||||
Loading…
Reference in New Issue
Block a user