1638 lines
59 KiB
Python
1638 lines
59 KiB
Python
|
|
import time
|
|||
|
|
import os.path
|
|||
|
|
import typing
|
|||
|
|
import json
|
|||
|
|
import public
|
|||
|
|
import threading
|
|||
|
|
import logging
|
|||
|
|
from power_mta.maillog_stat import query_maillog_with_time_section, last_maillog_time
|
|||
|
|
from public.regexplib import match_email
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Trigger:
|
|||
|
|
# 触发器类型
|
|||
|
|
TYPE_SUBSCRIBER_ADDED = 'subscriber_added'
|
|||
|
|
TYPE_SUBSCRIBED = 'subscribed'
|
|||
|
|
TYPE_OPENED = 'opened'
|
|||
|
|
TYPE_CLICKED = 'clicked'
|
|||
|
|
TYPE_UNSUBSCRIBED = 'unsubscribed'
|
|||
|
|
|
|||
|
|
__slots__ = ['type', '__attributes']
|
|||
|
|
|
|||
|
|
def __init__(self, trigger_dict: typing.Dict):
|
|||
|
|
self.__attributes: typing.Dict = {}
|
|||
|
|
|
|||
|
|
for k, v in trigger_dict.items():
|
|||
|
|
if k.startswith('_'):
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if k in self.__slots__:
|
|||
|
|
self.__setattr__(k, v)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
self.__attributes[k] = v
|
|||
|
|
|
|||
|
|
def get_attribute(self, attribute: typing.Optional[str] = None, default: typing.Any = None):
|
|||
|
|
# 返回所有attribute
|
|||
|
|
if attribute is None:
|
|||
|
|
return self.__attributes
|
|||
|
|
|
|||
|
|
return self.__attributes.get(attribute, default)
|
|||
|
|
|
|||
|
|
def to_dict(self):
|
|||
|
|
d = {}
|
|||
|
|
|
|||
|
|
for k in self.__slots__:
|
|||
|
|
if not hasattr(self, k) or k.startswith('_'):
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
d[k] = getattr(self, k)
|
|||
|
|
|
|||
|
|
d.update(self.__attributes)
|
|||
|
|
|
|||
|
|
return d
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Triggers:
|
|||
|
|
__slots__ = ['triggers']
|
|||
|
|
|
|||
|
|
def __init__(self, triggers: typing.List[typing.Dict]):
|
|||
|
|
self.triggers: typing.List[Trigger] = []
|
|||
|
|
|
|||
|
|
for trigger_dict in triggers:
|
|||
|
|
self.triggers.append(Trigger(trigger_dict))
|
|||
|
|
|
|||
|
|
def match(self, trigger_type: str, group_ids: typing.Union[int, typing.List[int]] = 0) -> bool:
|
|||
|
|
trigger_type = trigger_type.strip()
|
|||
|
|
for trigger in self.triggers:
|
|||
|
|
if trigger.type == trigger_type:
|
|||
|
|
if trigger_type in (Trigger.TYPE_SUBSCRIBED, Trigger.TYPE_SUBSCRIBER_ADDED):
|
|||
|
|
if group_ids == 0:
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
if isinstance(group_ids, (int, str)):
|
|||
|
|
group_ids = [int(group_ids)]
|
|||
|
|
|
|||
|
|
for group_id in group_ids:
|
|||
|
|
if int(group_id) in trigger.get_attribute('group_ids', []):
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
return True
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def complete_check(self) -> bool:
|
|||
|
|
for trigger in self.triggers:
|
|||
|
|
if trigger.type in (Trigger.TYPE_SUBSCRIBED, Trigger.TYPE_SUBSCRIBER_ADDED):
|
|||
|
|
# 检查订阅触发器
|
|||
|
|
if len(trigger.get_attribute('group_ids', [])) == 0:
|
|||
|
|
return False
|
|||
|
|
elif trigger.type == Trigger.TYPE_OPENED:
|
|||
|
|
# 检查打开邮件触发器
|
|||
|
|
pass
|
|||
|
|
elif trigger.type == Trigger.TYPE_CLICKED:
|
|||
|
|
# 检查点击邮件触发器
|
|||
|
|
pass
|
|||
|
|
elif trigger.type == Trigger.TYPE_UNSUBSCRIBED:
|
|||
|
|
# 检查取消订阅触发器
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
def to_list(self):
|
|||
|
|
return list(map(lambda x: x.to_dict(), self.triggers))
|
|||
|
|
|
|||
|
|
def json_dumps(self):
|
|||
|
|
return json.dumps(self.to_list())
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Node:
|
|||
|
|
TYPE_DELAY = 'delay'
|
|||
|
|
TYPE_EMAIL = 'email'
|
|||
|
|
TYPE_CONDITION = 'condition'
|
|||
|
|
TYPE_ACTION = 'action'
|
|||
|
|
TYPE_WEBHOOK = 'webhook'
|
|||
|
|
TYPE_ABTEST = 'abtest'
|
|||
|
|
|
|||
|
|
CONDITION_LOGIC_AND = 'and'
|
|||
|
|
CONDITION_LOGIC_OR = 'or'
|
|||
|
|
|
|||
|
|
CONDITION_BRANCH_YES = 'yes'
|
|||
|
|
CONDITION_BRANCH_NO = 'no'
|
|||
|
|
|
|||
|
|
DELAY_UNIT_DAYS = 'days'
|
|||
|
|
DELAY_UNIT_HOURS = 'hours'
|
|||
|
|
DELAY_UNIT_MINUTES = 'minutes'
|
|||
|
|
DELAY_UNIT_SECONDS = 'seconds'
|
|||
|
|
|
|||
|
|
ACTION_ADD_TO_SUBSCRIBERS = 'add_to_subscribers'
|
|||
|
|
ACTION_REMOVE_FROM_SUBSCRIBERS = 'remove_from_subscribers'
|
|||
|
|
ACTION_MARK_AS_UNSUBSCRIBE = 'mark_as_unsubscribe'
|
|||
|
|
ACTION_MOVE_TO_STEP = 'move_to_step'
|
|||
|
|
|
|||
|
|
__slots__ = ['id', 'parent_id', 'broken', 'complete', 'type', 'attributes', 'create_time', 'update_time',
|
|||
|
|
'__attributes', '__next', '__yes', '__no', '__branches']
|
|||
|
|
|
|||
|
|
__int_bool_converts = ('complete', 'broken')
|
|||
|
|
|
|||
|
|
__ignore_props = ('id',)
|
|||
|
|
|
|||
|
|
def __init__(self, node_dict: typing.Dict, parent_id: int = -1):
|
|||
|
|
self.id = node_dict.get('id', 0)
|
|||
|
|
self.parent_id = 0
|
|||
|
|
|
|||
|
|
if self.id == 0:
|
|||
|
|
self.id = public.snow_flake()
|
|||
|
|
|
|||
|
|
self.type = ''
|
|||
|
|
self.complete = False
|
|||
|
|
self.broken = False
|
|||
|
|
self.__attributes: typing.Dict = {}
|
|||
|
|
self.__next: typing.Optional[Node] = None
|
|||
|
|
self.__yes: typing.Optional[Node] = None
|
|||
|
|
self.__no: typing.Optional[Node] = None
|
|||
|
|
self.__branches: typing.List[Node] = []
|
|||
|
|
|
|||
|
|
for k, v in node_dict.items():
|
|||
|
|
if k.startswith('_') or k in self.__ignore_props:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if k == 'next':
|
|||
|
|
if isinstance(v, dict):
|
|||
|
|
self.set_next(Node(v, self.id))
|
|||
|
|
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if k in (self.CONDITION_BRANCH_YES, self.CONDITION_BRANCH_NO) and isinstance(v, dict):
|
|||
|
|
v['condition_branch'] = k
|
|||
|
|
self.set_next(Node(v, self.id))
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if k in self.__slots__:
|
|||
|
|
if k in self.__int_bool_converts:
|
|||
|
|
v = bool(v)
|
|||
|
|
self.__setattr__(k, v)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
self.__attributes[k] = v
|
|||
|
|
|
|||
|
|
if parent_id > -1:
|
|||
|
|
self.parent_id = parent_id
|
|||
|
|
|
|||
|
|
if hasattr(self, 'attributes'):
|
|||
|
|
try:
|
|||
|
|
m = json.loads(self.attributes)
|
|||
|
|
if isinstance(m, dict):
|
|||
|
|
m.update(self.__attributes)
|
|||
|
|
self.__attributes = m
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
self.attributes = json.dumps(self.__attributes, ensure_ascii=False)
|
|||
|
|
|
|||
|
|
self.complete_check()
|
|||
|
|
self.broken_check()
|
|||
|
|
|
|||
|
|
cur_time = int(time.time())
|
|||
|
|
|
|||
|
|
self.update_time = cur_time
|
|||
|
|
|
|||
|
|
if not hasattr(self, 'create_time'):
|
|||
|
|
self.create_time = cur_time
|
|||
|
|
|
|||
|
|
def to_tree_dict(self):
|
|||
|
|
root = {}
|
|||
|
|
|
|||
|
|
for k in self.__slots__:
|
|||
|
|
if not hasattr(self, k) or k.startswith('_') or k == 'attributes':
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
root[k] = getattr(self, k)
|
|||
|
|
|
|||
|
|
for k, v in self.get_attribute().items():
|
|||
|
|
root[k] = v
|
|||
|
|
|
|||
|
|
if self.type == self.TYPE_CONDITION:
|
|||
|
|
if isinstance(self.__yes, Node):
|
|||
|
|
root['yes'] = self.__yes.to_tree_dict()
|
|||
|
|
|
|||
|
|
if isinstance(self.__no, Node):
|
|||
|
|
root['no'] = self.__no.to_tree_dict()
|
|||
|
|
elif self.type == self.TYPE_ABTEST:
|
|||
|
|
root['branches'] = []
|
|||
|
|
for branch in self.__branches:
|
|||
|
|
root['branches'].append(branch.to_tree_dict())
|
|||
|
|
|
|||
|
|
root['next'] = None if self.next() is None else self.next().to_tree_dict()
|
|||
|
|
|
|||
|
|
return root
|
|||
|
|
|
|||
|
|
def next(self, attribute_or_index: typing.Optional[typing.Union[str, int]] = None, default: typing.Any = None):
|
|||
|
|
if attribute_or_index is not None:
|
|||
|
|
if isinstance(attribute_or_index, str):
|
|||
|
|
if attribute_or_index == 'yes':
|
|||
|
|
return self.__yes
|
|||
|
|
elif attribute_or_index == 'no':
|
|||
|
|
return self.__no
|
|||
|
|
elif attribute_or_index == 'branches':
|
|||
|
|
return self.__branches
|
|||
|
|
|
|||
|
|
return default
|
|||
|
|
elif isinstance(attribute_or_index, int):
|
|||
|
|
return self.__branches[attribute_or_index]
|
|||
|
|
|
|||
|
|
return self.__next
|
|||
|
|
|
|||
|
|
def set_next(self, node):
|
|||
|
|
if self.type == self.TYPE_CONDITION:
|
|||
|
|
|
|||
|
|
if node.get_attribute('condition_branch') == self.CONDITION_BRANCH_YES:
|
|||
|
|
self.__yes = node
|
|||
|
|
elif node.get_attribute('condition_branch') == self.CONDITION_BRANCH_NO:
|
|||
|
|
self.__no = node
|
|||
|
|
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
if self.type == self.TYPE_ABTEST:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
self.__next = node
|
|||
|
|
|
|||
|
|
def get_attribute(self, attribute: typing.Optional[str] = None, default: typing.Any = None):
|
|||
|
|
# 返回所有attribute
|
|||
|
|
if attribute is None:
|
|||
|
|
return self.__attributes
|
|||
|
|
|
|||
|
|
return self.__attributes.get(attribute, default)
|
|||
|
|
|
|||
|
|
def set_attribute(self, name: str, value: typing.Any):
|
|||
|
|
self.__attributes[name] = value
|
|||
|
|
self.attributes = json.dumps(self.__attributes, ensure_ascii=False)
|
|||
|
|
return self
|
|||
|
|
|
|||
|
|
def to_dict(self):
|
|||
|
|
d = {}
|
|||
|
|
|
|||
|
|
for k in self.__slots__:
|
|||
|
|
if not hasattr(self, k) or k.startswith('_'):
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
d[k] = getattr(self, k)
|
|||
|
|
|
|||
|
|
return d
|
|||
|
|
|
|||
|
|
def complete_check(self):
|
|||
|
|
if self.type == self.TYPE_DELAY:
|
|||
|
|
self.complete = self.get_attribute('value', 0) > 0 and self.get_attribute('unit', '') in ('days', 'hours', 'minutes', 'seconds')
|
|||
|
|
elif self.type == self.TYPE_EMAIL:
|
|||
|
|
try:
|
|||
|
|
public.to_dict_obj(self.get_attribute()).validate([
|
|||
|
|
public.Param('email_id').Require().Integer('>', 0),
|
|||
|
|
public.Param('campaign_id').Require().Integer('>', 0),
|
|||
|
|
public.Param('name').Require().string('>', 0),
|
|||
|
|
public.Param('subject').Require().string('>', 0),
|
|||
|
|
public.Param('from').Require().Email(),
|
|||
|
|
public.Param('get_attribute').Require().String('>', 0),
|
|||
|
|
])
|
|||
|
|
except:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
self.complete = True
|
|||
|
|
elif self.type == self.TYPE_CONDITION:
|
|||
|
|
self.complete = len(self.get_attribute('rules', [])) > 0
|
|||
|
|
elif self.type == self.TYPE_ACTION:
|
|||
|
|
self.complete = self.get_attribute('action', '') != ''
|
|||
|
|
elif self.type == self.TYPE_WEBHOOK:
|
|||
|
|
try:
|
|||
|
|
public.to_dict_obj(self.get_attribute()).validate([
|
|||
|
|
public.Param('url').Require().Url(),
|
|||
|
|
public.Param('secret').Require().string('>', 0),
|
|||
|
|
])
|
|||
|
|
except:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
self.complete = True
|
|||
|
|
elif self.type == self.TYPE_ABTEST:
|
|||
|
|
self.complete = self.get_attribute('name', '') != '' and len(self.get_attribute('branches', [])) > 0
|
|||
|
|
|
|||
|
|
def broken_check(self):
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
def base_validate(self):
|
|||
|
|
# node转换成dict_obj对象,方便校验数据结构
|
|||
|
|
node_obj = public.to_dict_obj(self.to_dict())
|
|||
|
|
|
|||
|
|
# node基本数据结构验证
|
|||
|
|
node_obj.validate([
|
|||
|
|
public.Param('id').Filter(int),
|
|||
|
|
public.Param('parent_id').Filter(int),
|
|||
|
|
public.Param('type').Require().String('in', [Node.TYPE_DELAY, Node.TYPE_EMAIL, Node.TYPE_CONDITION, Node.TYPE_ACTION, Node.TYPE_ABTEST, Node.TYPE_WEBHOOK]),
|
|||
|
|
public.Param('next').Require(),
|
|||
|
|
public.Param('broken').Require().Bool(),
|
|||
|
|
public.Param('complete').Require().Bool(),
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
# 根据node_type再次验证专属数据结构
|
|||
|
|
if node_obj.type == Node.TYPE_EMAIL:
|
|||
|
|
# email节点数据结构验证
|
|||
|
|
node_obj.validate([
|
|||
|
|
public.Param('email_id').Filter(int),
|
|||
|
|
public.Param('campaign_id').Filter(int),
|
|||
|
|
public.Param('name').Require(),
|
|||
|
|
public.Param('subject').Require(),
|
|||
|
|
public.Param('from').Require(),
|
|||
|
|
public.Param('from_name').Require(),
|
|||
|
|
public.Param('track_opens').Require().Bool(),
|
|||
|
|
public.Param('track_clicks').Require().Bool(),
|
|||
|
|
public.Param('track_unsubscribe').Require().Bool(),
|
|||
|
|
])
|
|||
|
|
elif node_obj.type == Node.TYPE_DELAY:
|
|||
|
|
# delay节点数据结构验证
|
|||
|
|
node_obj.validate([
|
|||
|
|
public.Param('value').Require().Filter(int),
|
|||
|
|
public.Param('unit').Require().String('in', [Node.DELAY_UNIT_DAYS, Node.DELAY_UNIT_HOURS, Node.DELAY_UNIT_MINUTES, Node.DELAY_UNIT_SECONDS]),
|
|||
|
|
public.Param('description').Require(),
|
|||
|
|
])
|
|||
|
|
elif node_obj.type == Node.TYPE_CONDITION:
|
|||
|
|
# condition节点数据结构验证
|
|||
|
|
node_obj.validate([
|
|||
|
|
public.Param('rules').Require().List(),
|
|||
|
|
public.Param('logic_type').Require().String('in', [Node.CONDITION_LOGIC_AND, Node.CONDITION_LOGIC_OR]),
|
|||
|
|
public.Param('yes').Require(),
|
|||
|
|
public.Param('no').Require(),
|
|||
|
|
public.Param('description').Require(),
|
|||
|
|
])
|
|||
|
|
elif node_obj.type == Node.TYPE_WEBHOOK:
|
|||
|
|
# webhook节点数据结构验证
|
|||
|
|
node_obj.validate([
|
|||
|
|
public.Param('url').Require(),
|
|||
|
|
public.Param('secret').Require(),
|
|||
|
|
])
|
|||
|
|
elif node_obj.type == Node.TYPE_ACTION:
|
|||
|
|
# action节点数据结构验证
|
|||
|
|
node_obj.validate([
|
|||
|
|
public.Param('action').Require(),
|
|||
|
|
public.Param('group_ids').Require().List(),
|
|||
|
|
public.Param('groups').List(),
|
|||
|
|
public.Param('description').Require(),
|
|||
|
|
])
|
|||
|
|
elif node_obj.type == Node.TYPE_ABTEST:
|
|||
|
|
# abtest节点数据结构验证
|
|||
|
|
node_obj.validate([
|
|||
|
|
public.Param('name').Require(),
|
|||
|
|
public.Param('branches').Require().List(),
|
|||
|
|
])
|
|||
|
|
elif node_obj.get('abtestbranch', False):
|
|||
|
|
# abtestfbranch节点数据结构验证
|
|||
|
|
node_obj.validate([
|
|||
|
|
public.Param('label').Require(),
|
|||
|
|
public.Param('path').Require().String('in', ['a', 'b', 'c']),
|
|||
|
|
public.Param('percentage').Require().Integer('between', [1, 100]),
|
|||
|
|
])
|
|||
|
|
else:
|
|||
|
|
raise public.HintException(public.lang('Invalid node type: {}', self.type))
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ConditionRule:
|
|||
|
|
TYPE_CAMPAIGN = 'campaign'
|
|||
|
|
TYPE_WORKFLOW = 'workflow'
|
|||
|
|
TYPE_RECIPIENT = 'recipient'
|
|||
|
|
|
|||
|
|
__slots__ = ['type', 'action', '__attributes']
|
|||
|
|
|
|||
|
|
def __init__(self, condition_rule_dict: typing.Dict):
|
|||
|
|
self.type = ''
|
|||
|
|
self.action = ''
|
|||
|
|
self.__attributes: typing.Dict = {}
|
|||
|
|
|
|||
|
|
for k, v in condition_rule_dict.items():
|
|||
|
|
if k.startswith('_'):
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if k in self.__slots__:
|
|||
|
|
self.__setattr__(k, v)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
self.__attributes[k] = v
|
|||
|
|
|
|||
|
|
def get_attribute(self, attribute: typing.Optional[str] = None, default: typing.Any = None):
|
|||
|
|
# 返回所有attribute
|
|||
|
|
if attribute is None:
|
|||
|
|
return self.__attributes
|
|||
|
|
|
|||
|
|
return self.__attributes.get(attribute, default)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ConditionRules:
|
|||
|
|
__slots__ = ['rules', 'logic']
|
|||
|
|
|
|||
|
|
def __init__(self, rules: typing.List[typing.Dict], logic: str = Node.CONDITION_LOGIC_AND):
|
|||
|
|
self.rules: typing.List[ConditionRule] = []
|
|||
|
|
self.logic: str = logic
|
|||
|
|
|
|||
|
|
for rule_dict in rules:
|
|||
|
|
self.rules.append(ConditionRule(rule_dict))
|
|||
|
|
|
|||
|
|
def match(self) -> bool:
|
|||
|
|
for rule in self.rules:
|
|||
|
|
if rule.type in (ConditionRule.TYPE_CAMPAIGN, ConditionRule.TYPE_WORKFLOW):
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if rule.type == ConditionRule.TYPE_RECIPIENT:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Workflow:
|
|||
|
|
|
|||
|
|
__db_path = '/www/vmail/mail_automation_workflows'
|
|||
|
|
|
|||
|
|
def __init__(self, automation_id: int):
|
|||
|
|
self.__automation_id = automation_id
|
|||
|
|
self.__create_tables()
|
|||
|
|
|
|||
|
|
def __create_tables(self):
|
|||
|
|
if not os.path.exists(self.__db_path):
|
|||
|
|
os.makedirs(self.__db_path, 0o700)
|
|||
|
|
|
|||
|
|
with public.S(None, '{}/workflow_{}'.format(self.__db_path, self.__automation_id)) as query:
|
|||
|
|
query.execute_script('''-- 开启wal模式
|
|||
|
|
PRAGMA journal_mode = wal;
|
|||
|
|
|
|||
|
|
-- 关闭同步
|
|||
|
|
PRAGMA synchronous = 0;
|
|||
|
|
|
|||
|
|
-- 开启事务
|
|||
|
|
begin;
|
|||
|
|
|
|||
|
|
-- 节点表
|
|||
|
|
create table if not exists `nodes` (
|
|||
|
|
`id` integer primary key,
|
|||
|
|
`parent_id` integer not null default 0,
|
|||
|
|
`broken` integer not null default 0,
|
|||
|
|
`complete` integer not null default 0,
|
|||
|
|
`create_time` integer not null default (strftime('%s')),
|
|||
|
|
`update_time` integer not null default (strftime('%s')),
|
|||
|
|
`type` text not null default '',
|
|||
|
|
`attributes` text not null default '{}'
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
create index if not exists `node_parentId` on `nodes` (`parent_id`);
|
|||
|
|
|
|||
|
|
-- 工作流程进行表
|
|||
|
|
create table if not exists `schedules` (
|
|||
|
|
`id` integer primary key autoincrement,
|
|||
|
|
`node_id` integer not null default 0,
|
|||
|
|
`status` integer not null default 0,
|
|||
|
|
`create_time` integer not null default (strftime('%s')),
|
|||
|
|
`update_time` integer not null default (strftime('%s')),
|
|||
|
|
`subscriber_email` text not null default ''
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
create index if not exists `schedule_subscriberEmail_status` on `schedules` (`subscriber_email`, `status`);
|
|||
|
|
|
|||
|
|
-- 提交事务
|
|||
|
|
commit;
|
|||
|
|
''')
|
|||
|
|
|
|||
|
|
def query(self, table_name: str):
|
|||
|
|
return public.S(table_name, '{}/workflow_{}'.format(self.__db_path, self.__automation_id))
|
|||
|
|
|
|||
|
|
def __get_node_map(self) -> typing.Dict[int, Node]:
|
|||
|
|
with self.query('nodes') as query:
|
|||
|
|
nodes = [Node(node_dict) for node_dict in query.select()]
|
|||
|
|
|
|||
|
|
node_map = {}
|
|||
|
|
|
|||
|
|
for node in nodes:
|
|||
|
|
node_map[node.id] = node
|
|||
|
|
|
|||
|
|
return node_map
|
|||
|
|
|
|||
|
|
def load_workflow_tree(self):
|
|||
|
|
return self.build_workflow_tree(self.__get_node_map())
|
|||
|
|
|
|||
|
|
def set_nodes(self, root: typing.Optional[typing.Dict]) -> typing.Optional[Node]:
|
|||
|
|
if root is None:
|
|||
|
|
# 清空节点树
|
|||
|
|
with self.query('nodes') as query:
|
|||
|
|
query.delete()
|
|||
|
|
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
insert_data = []
|
|||
|
|
node_map = {}
|
|||
|
|
|
|||
|
|
# 解析nodes
|
|||
|
|
for node in self.walk_node(root):
|
|||
|
|
# 处理特殊节点
|
|||
|
|
if node.type == Node.TYPE_EMAIL:
|
|||
|
|
# 邮件发送任务节点
|
|||
|
|
pass
|
|||
|
|
elif node.type == Node.TYPE_CONDITION:
|
|||
|
|
# 条件分支节点
|
|||
|
|
pass
|
|||
|
|
elif node.type == Node.TYPE_ABTEST:
|
|||
|
|
# AB测试节点
|
|||
|
|
pass
|
|||
|
|
elif node.get_attribute('abtestbranch', False):
|
|||
|
|
# AB测试子节点根节点
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
insert_data.append(node.to_dict())
|
|||
|
|
node_map[node.id] = node
|
|||
|
|
|
|||
|
|
# 将节点数据写入数据库
|
|||
|
|
if len(insert_data) > 0:
|
|||
|
|
with self.query('nodes') as query:
|
|||
|
|
query.insert_all(insert_data, option='REPLACE')
|
|||
|
|
|
|||
|
|
# 删除无效节点
|
|||
|
|
query.where_not_in('id', list(node_map.keys())).delete()
|
|||
|
|
|
|||
|
|
# 生成新的节点树并返回
|
|||
|
|
return self.build_workflow_tree(node_map)
|
|||
|
|
|
|||
|
|
def build_workflow_tree(self, node_map: typing.Dict[int, Node], root_id: int = 0) -> typing.Optional[Node]:
|
|||
|
|
root = None
|
|||
|
|
|
|||
|
|
for node_id, node in node_map.items():
|
|||
|
|
# 检查是否root节点
|
|||
|
|
if node.parent_id == 0 or node.id == root_id:
|
|||
|
|
root = node
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if node.parent_id not in node_map:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
node_map[node.parent_id].set_next(node)
|
|||
|
|
|
|||
|
|
return root
|
|||
|
|
|
|||
|
|
def walk_node(self, node_or_dict: typing.Union[typing.Dict, Node], p_node: typing.Optional[Node] = None):
|
|||
|
|
# node_dict转换成Node对象,方便校验数据结构
|
|||
|
|
if isinstance(node_or_dict, Node):
|
|||
|
|
node = node_or_dict
|
|||
|
|
else:
|
|||
|
|
node = Node(node_or_dict, p_node.id if p_node is not None else 0)
|
|||
|
|
|
|||
|
|
# 返回节点信息
|
|||
|
|
yield node
|
|||
|
|
|
|||
|
|
# 处理特殊节点
|
|||
|
|
if node.type == Node.TYPE_CONDITION:
|
|||
|
|
if node.next('yes'):
|
|||
|
|
yield from self.walk_node(node.next('yes'), node)
|
|||
|
|
if node.next('no'):
|
|||
|
|
yield from self.walk_node(node.next('no'), node)
|
|||
|
|
elif node.type == Node.TYPE_ABTEST:
|
|||
|
|
for abtestbranch in node.next('branches', []):
|
|||
|
|
yield from self.walk_node(abtestbranch, node)
|
|||
|
|
|
|||
|
|
if node.next():
|
|||
|
|
yield from self.walk_node(node.next(), node)
|
|||
|
|
|
|||
|
|
def walk_node_for_schedule(self, root_id: int = 0):
|
|||
|
|
with self.query('nodes') as query:
|
|||
|
|
query.where('parent_id', root_id)
|
|||
|
|
|
|||
|
|
if root_id > 0:
|
|||
|
|
query.where_or('id', root_id)
|
|||
|
|
|
|||
|
|
nodes = [Node(node_dict) for node_dict in query.select()]
|
|||
|
|
|
|||
|
|
if len(nodes) == 0:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# 生成新的节点树并返回
|
|||
|
|
workflow_tree = self.build_workflow_tree({node.id: node for node in nodes}, root_id)
|
|||
|
|
|
|||
|
|
if workflow_tree is None:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
yield from self.walk_node(workflow_tree)
|
|||
|
|
|
|||
|
|
def walk_node_with_database(self):
|
|||
|
|
yield from self.walk_node(self.load_workflow_tree())
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Automation:
|
|||
|
|
|
|||
|
|
__db_path = '/www/vmail'
|
|||
|
|
|
|||
|
|
def __init__(self):
|
|||
|
|
self.__IS_INIT = False
|
|||
|
|
self.__create_tables()
|
|||
|
|
|
|||
|
|
def is_init(self) -> bool:
|
|||
|
|
self.__create_tables()
|
|||
|
|
return self.__IS_INIT
|
|||
|
|
|
|||
|
|
def __create_tables(self):
|
|||
|
|
if self.__IS_INIT:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
if not os.path.exists(self.__db_path):
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
self.__IS_INIT = True
|
|||
|
|
|
|||
|
|
with self.query() as query:
|
|||
|
|
query.execute_script('''-- 开启wal模式
|
|||
|
|
PRAGMA journal_mode = wal;
|
|||
|
|
|
|||
|
|
-- 关闭同步
|
|||
|
|
PRAGMA synchronous = 0;
|
|||
|
|
|
|||
|
|
-- 开启事务
|
|||
|
|
begin;
|
|||
|
|
|
|||
|
|
-- 任务表
|
|||
|
|
create table if not exists `automations` (
|
|||
|
|
`id` integer primary key autoincrement,
|
|||
|
|
`status` integer not null default 0,
|
|||
|
|
`sent` integer not null default 0,
|
|||
|
|
`delivered` integer not null default 0,
|
|||
|
|
`opened` integer not null default 0,
|
|||
|
|
`clicked` integer not null default 0,
|
|||
|
|
`create_time` integer not null default (strftime('%s')),
|
|||
|
|
`update_time` integer not null default (strftime('%s')),
|
|||
|
|
`last_stat_time` integer not null default 0,
|
|||
|
|
`name` text not null default '',
|
|||
|
|
`triggers` text not null default '[]'
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
-- 提交事务
|
|||
|
|
commit;
|
|||
|
|
''')
|
|||
|
|
|
|||
|
|
def query(self):
|
|||
|
|
self.__create_tables()
|
|||
|
|
return public.S('automations', '{}/automations'.format(self.__db_path))
|
|||
|
|
|
|||
|
|
# 获取自动化任务列表
|
|||
|
|
def get_tasks(self, args: public.dict_obj):
|
|||
|
|
args.validate([
|
|||
|
|
public.Param('p').Integer('>', 0),
|
|||
|
|
public.Param('p_size').Integer('>', 0),
|
|||
|
|
public.Param('keyword').Xss(),
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
with self.query() as query:
|
|||
|
|
query.where_in('status', [0, 1])\
|
|||
|
|
.field('id', 'status', 'name', 'create_time', 'update_time', 'sent', 'delivered', 'ifnull(round(1.0 * opened / sent * 100, 2), 0) as `opened`', 'ifnull(round(1.0 * clicked / sent * 100, 2), 0) as `clicked`')
|
|||
|
|
|
|||
|
|
if 'keyword' in args and args.keyword != '':
|
|||
|
|
query.where('`name` like ?', '%{}%'.format(args.keyword))
|
|||
|
|
|
|||
|
|
ret = {
|
|||
|
|
'total': query.fork().count(),
|
|||
|
|
'list': query.order('id', 'desc').skip((args.get('p', 1) - 1) * args.get('p_size', 20)).limit(args.get('p_size', 20)).select(),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return ret
|
|||
|
|
|
|||
|
|
# 创建/编辑邮件自动化任务
|
|||
|
|
def set(self, args: public.dict_obj):
|
|||
|
|
args.validate([
|
|||
|
|
public.Param('id').Integer('>', 0),
|
|||
|
|
public.Param('name').String('>', 0),
|
|||
|
|
public.Param('triggers').Require().List(),
|
|||
|
|
public.Param('root').Require(),
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
triggers = Triggers(args.triggers)
|
|||
|
|
|
|||
|
|
with self.query() as query:
|
|||
|
|
if 'id' in args:
|
|||
|
|
automation_id = args.id
|
|||
|
|
|
|||
|
|
if not query.where('id', automation_id).exists():
|
|||
|
|
raise public.HintException(public.lang('Invalid automation'))
|
|||
|
|
|
|||
|
|
if query.where('id', automation_id).where('status', 1).exists():
|
|||
|
|
raise public.HintException(public.lang('Cannot edit automation in running state.'))
|
|||
|
|
|
|||
|
|
update_data = {
|
|||
|
|
'triggers': triggers.json_dumps(),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if 'name' in args:
|
|||
|
|
update_data['name'] = args.name
|
|||
|
|
|
|||
|
|
query.where('id', automation_id).update(update_data)
|
|||
|
|
else:
|
|||
|
|
insert_data = {
|
|||
|
|
'triggers': triggers.json_dumps(),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if 'name' in args:
|
|||
|
|
insert_data['name'] = args.name
|
|||
|
|
|
|||
|
|
automation_id = query.insert(insert_data)
|
|||
|
|
|
|||
|
|
wf = Workflow(automation_id)
|
|||
|
|
|
|||
|
|
root = wf.set_nodes(args.root)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
'id': automation_id,
|
|||
|
|
'triggers': triggers.to_list(),
|
|||
|
|
'root': root.to_tree_dict() if root is not None else None,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 更新任务名称
|
|||
|
|
def set_name(self, args: public.dict_obj):
|
|||
|
|
args.validate([
|
|||
|
|
public.Param('id').Require().Integer('>', 0),
|
|||
|
|
public.Param('name').Require().String('>', 0),
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
with self.query() as query:
|
|||
|
|
query.where('id', args.id).update({
|
|||
|
|
'name': args.name
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return public.lang('Success')
|
|||
|
|
|
|||
|
|
# 获取节点树
|
|||
|
|
def get_workflow(self, args: public.dict_obj):
|
|||
|
|
args.validate([
|
|||
|
|
public.Param('id').Require().Integer('>', 0),
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
with self.query() as query:
|
|||
|
|
triggers_str = query.where('id', args.id).value('triggers')
|
|||
|
|
|
|||
|
|
if triggers_str is None:
|
|||
|
|
raise public.HintException(public.lang('Invalid automation_id: {}', args.id))
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
triggers = Triggers(json.loads(triggers_str))
|
|||
|
|
except:
|
|||
|
|
raise public.HintException(public.lang('Invalid automation: failed to load triggers'))
|
|||
|
|
|
|||
|
|
wf = Workflow(args.id)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
'id': args.id,
|
|||
|
|
'triggers': triggers.to_list(),
|
|||
|
|
'root': wf.load_workflow_tree().to_tree_dict(),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 启停邮件自动化任务
|
|||
|
|
def set_status(self, args: public.dict_obj):
|
|||
|
|
args.validate([
|
|||
|
|
public.Param('id').Require().Integer('>', 0),
|
|||
|
|
public.Param('status').Require().Integer('in', [0, 1]),
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
# 启动任务时检查所有节点是否配置
|
|||
|
|
if args.status == 1:
|
|||
|
|
# 检查triggers是否配置
|
|||
|
|
with self.query() as query:
|
|||
|
|
try:
|
|||
|
|
triggers = Triggers(json.loads(query.where('id', args.id).value('triggers')))
|
|||
|
|
except:
|
|||
|
|
raise public.HintException(public.lang('Invalid automation: failed to load triggers'))
|
|||
|
|
|
|||
|
|
if not triggers.complete_check():
|
|||
|
|
raise public.HintException(public.lang('Invalid automation: incomplete triggers'))
|
|||
|
|
|
|||
|
|
task = Task()
|
|||
|
|
wf = Workflow(args.id)
|
|||
|
|
for node in wf.walk_node_with_database():
|
|||
|
|
if not node.complete:
|
|||
|
|
raise public.HintException(public.lang('You must complete workflow'))
|
|||
|
|
|
|||
|
|
# 为Email节点创建邮件任务
|
|||
|
|
if node.type == Node.TYPE_EMAIL:
|
|||
|
|
# 更新邮件任务
|
|||
|
|
node.set_attribute('campaign_id', task.set_email_campaign(node))
|
|||
|
|
with wf.query('nodes') as query:
|
|||
|
|
query.where('id', node.id).update(node.to_dict())
|
|||
|
|
|
|||
|
|
with self.query() as query:
|
|||
|
|
query.where('id', args.id).update({
|
|||
|
|
'status': args.status,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return public.lang('Success')
|
|||
|
|
|
|||
|
|
# 删除邮件自动化任务
|
|||
|
|
def remove(self, args: public.dict_obj):
|
|||
|
|
args.validate([
|
|||
|
|
public.Param('id').Require().Integer('>', 0),
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
with self.query() as query:
|
|||
|
|
# 标记软删除
|
|||
|
|
query.where('id', args.id).update({
|
|||
|
|
'status': 2,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return public.lang('Success')
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ScheduleNode:
|
|||
|
|
STATUS_WAITING = 0
|
|||
|
|
STATUS_PROCESSING = 1
|
|||
|
|
STATUS_COMPLETED = 2
|
|||
|
|
STATUS_BROKEN = 3
|
|||
|
|
|
|||
|
|
__slots__ = ['id', 'node_id', 'status', 'subscriber_email', 'update_time']
|
|||
|
|
|
|||
|
|
def __init__(self, node_dict: typing.Dict):
|
|||
|
|
self.id = 0
|
|||
|
|
self.node_id = 0
|
|||
|
|
self.status = self.STATUS_WAITING
|
|||
|
|
self.subscriber_email = ''
|
|||
|
|
|
|||
|
|
for k in self.__slots__:
|
|||
|
|
if k not in node_dict:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
self.__setattr__(k, node_dict[k])
|
|||
|
|
|
|||
|
|
if not hasattr(self, 'update_time'):
|
|||
|
|
self.update_time = int(time.time())
|
|||
|
|
|
|||
|
|
def to_dict(self):
|
|||
|
|
d = {}
|
|||
|
|
|
|||
|
|
for k in self.__slots__:
|
|||
|
|
if not hasattr(self, k) or k.startswith('_'):
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
d[k] = getattr(self, k)
|
|||
|
|
|
|||
|
|
return d
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Scheduler:
|
|||
|
|
def __init__(self, automation_id: int):
|
|||
|
|
# 参数格式验证
|
|||
|
|
public.to_dict_obj({
|
|||
|
|
'automation_id': automation_id,
|
|||
|
|
}).validate([
|
|||
|
|
public.Param('automation_id').Integer('>', 0),
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
self.automation_id: int = automation_id
|
|||
|
|
self.automation: Automation = Automation()
|
|||
|
|
self.workflow: Workflow = Workflow(automation_id)
|
|||
|
|
|
|||
|
|
def schedule(self, subscriber: typing.Optional[str] = None):
|
|||
|
|
if subscriber is None:
|
|||
|
|
self.__schedule_all()
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# 参数格式验证
|
|||
|
|
public.to_dict_obj({
|
|||
|
|
'subscriber': subscriber
|
|||
|
|
}).validate([
|
|||
|
|
public.Param('subscriber').Email(),
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
with self.workflow.query('schedules') as query:
|
|||
|
|
schedule_node = query.where('subscriber_email', subscriber)\
|
|||
|
|
.order('id', 'desc')\
|
|||
|
|
.field('id', 'node_id', 'subscriber_email', 'status', 'update_time')\
|
|||
|
|
.find()
|
|||
|
|
|
|||
|
|
cur_time = int(time.time())
|
|||
|
|
|
|||
|
|
# 任务首次调度
|
|||
|
|
if schedule_node is None:
|
|||
|
|
schedule_node = ScheduleNode({
|
|||
|
|
'id': 0,
|
|||
|
|
'node_id': 0,
|
|||
|
|
'subscriber_email': subscriber,
|
|||
|
|
'status': ScheduleNode.STATUS_WAITING,
|
|||
|
|
'update_time': cur_time,
|
|||
|
|
})
|
|||
|
|
else:
|
|||
|
|
schedule_node = ScheduleNode(schedule_node)
|
|||
|
|
|
|||
|
|
self.__schedule_one(schedule_node, cur_time)
|
|||
|
|
|
|||
|
|
def schedule_async(self, subscriber: typing.Union[str, typing.List[str]]):
|
|||
|
|
if isinstance(subscriber, str):
|
|||
|
|
subscriber = [subscriber]
|
|||
|
|
|
|||
|
|
# 过滤邮件
|
|||
|
|
subscriber = list(filter(lambda x: match_email.match(x), subscriber))
|
|||
|
|
|
|||
|
|
if len(subscriber) == 0:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
cur_time = int(time.time())
|
|||
|
|
|
|||
|
|
with self.workflow.query('schedules') as query:
|
|||
|
|
exists_emails = query.where_in('subscriber_email', subscriber).field('distinct `subscriber_email`').column('subscriber_email')
|
|||
|
|
|
|||
|
|
insert_data = []
|
|||
|
|
|
|||
|
|
for email in subscriber:
|
|||
|
|
if email in exists_emails:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
insert_data.append({
|
|||
|
|
'node_id': 0,
|
|||
|
|
'status': ScheduleNode.STATUS_WAITING,
|
|||
|
|
'subscriber_email': email,
|
|||
|
|
'create_time': cur_time,
|
|||
|
|
'update_time': cur_time,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if len(insert_data) > 0:
|
|||
|
|
query.insert_all(insert_data)
|
|||
|
|
|
|||
|
|
def schedule_email_sending(self):
|
|||
|
|
cur_time = int(time.time())
|
|||
|
|
|
|||
|
|
# Add timeout threshold for stuck processing tasks (e.g., 1 hour)
|
|||
|
|
processing_timeout = 120
|
|||
|
|
|
|||
|
|
for node in self.workflow.walk_node_with_database():
|
|||
|
|
if node.type not in (Node.TYPE_EMAIL,):
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
logging.debug('schedule node: {} {}'.format(node.id, node.get_attribute('campaign_id', 0)))
|
|||
|
|
|
|||
|
|
with self.workflow.query('schedules') as query:
|
|||
|
|
schedules = query.where('node_id', node.id).where_in('status', [
|
|||
|
|
ScheduleNode.STATUS_WAITING,
|
|||
|
|
ScheduleNode.STATUS_PROCESSING
|
|||
|
|
]).field('id', 'subscriber_email', 'status', 'update_time').select()
|
|||
|
|
|
|||
|
|
if len(schedules) == 0:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
campaign_id = int(node.get_attribute('campaign_id', 0))
|
|||
|
|
schedule_ids = set()
|
|||
|
|
emails = set()
|
|||
|
|
|
|||
|
|
for schedule in schedules:
|
|||
|
|
# Only process waiting tasks and stuck processing tasks
|
|||
|
|
if schedule['status'] == ScheduleNode.STATUS_PROCESSING and (
|
|||
|
|
cur_time - schedule['update_time'] < processing_timeout):
|
|||
|
|
continue # Skip tasks that are still being processed within timeout
|
|||
|
|
|
|||
|
|
schedule_ids.add(schedule['id'])
|
|||
|
|
emails.add(schedule['subscriber_email'])
|
|||
|
|
|
|||
|
|
if len(schedule_ids) == 0:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if campaign_id < 1:
|
|||
|
|
query.where_in('id', list(schedule_ids)).update({
|
|||
|
|
'status': ScheduleNode.STATUS_BROKEN,
|
|||
|
|
'update_time': cur_time,
|
|||
|
|
})
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
with public.S('email_task', '/www/vmail/postfixadmin') as query:
|
|||
|
|
if not query.where('id', campaign_id).where('type', 1).exists():
|
|||
|
|
with self.workflow.query('scheduels') as query2:
|
|||
|
|
query2.where_in('id', list(schedule_ids)).update({
|
|||
|
|
'status': ScheduleNode.STATUS_BROKEN,
|
|||
|
|
'update_time': cur_time,
|
|||
|
|
})
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
campaign = query.where('id', campaign_id).where('type', 1).field(
|
|||
|
|
'task_process').find()
|
|||
|
|
|
|||
|
|
# 邮件任务执行中,等待一次调度
|
|||
|
|
if int(campaign['task_process']) == 1:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
recipient_file = '{}/data/mail/in_bulk/recipient/{}_verify_{}'.format(public.get_panel_path(),
|
|||
|
|
public.Md5(str(node.id)),
|
|||
|
|
campaign_id)
|
|||
|
|
|
|||
|
|
recipient_dict = {
|
|||
|
|
'gmail.com': {"count": 0, "info": list(emails)},
|
|||
|
|
'googlemail.com': {"count": 0, "info": []},
|
|||
|
|
'hotmail.com': {"count": 0, "info": []},
|
|||
|
|
'outlook.com': {"count": 0, "info": []},
|
|||
|
|
'yahoo.com': {"count": 0, "info": []},
|
|||
|
|
'icloud.com': {"count": 0, "info": []},
|
|||
|
|
'other': {"count": 0, "info": []},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
with open(recipient_file, 'w') as fp:
|
|||
|
|
fp.write(json.dumps(recipient_dict))
|
|||
|
|
|
|||
|
|
query.where('id', campaign_id).update({
|
|||
|
|
'task_process': 0,
|
|||
|
|
'recipient': recipient_file,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
db_dir = '/www/vmail/bulk'
|
|||
|
|
db_path = '{}/task_{}.db'.format(db_dir, campaign_id)
|
|||
|
|
|
|||
|
|
if os.path.exists(db_path):
|
|||
|
|
insert_data = []
|
|||
|
|
|
|||
|
|
for email in emails:
|
|||
|
|
insert_data.append({
|
|||
|
|
'recipient': email,
|
|||
|
|
'is_sent': 0,
|
|||
|
|
'mail_provider': email.split('@')[1],
|
|||
|
|
'sent_time': 0,
|
|||
|
|
'created': cur_time,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
with public.S('recipient_info', db_path) as query:
|
|||
|
|
query.insert_all(insert_data, option='IGNORE')
|
|||
|
|
|
|||
|
|
logging.debug('sending emails with automation: {}'.format(list(emails)))
|
|||
|
|
|
|||
|
|
with self.workflow.query('schedules') as query:
|
|||
|
|
query.where_in('id', list(schedule_ids)).update({
|
|||
|
|
'status': ScheduleNode.STATUS_PROCESSING,
|
|||
|
|
'update_time': cur_time,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
ret = public.run_plugin('mail_sys', 'check_task_status_new', public.to_dict_obj({
|
|||
|
|
'task_id': node.get_attribute('campaign_id', 0),
|
|||
|
|
}))
|
|||
|
|
|
|||
|
|
logging.debug(ret)
|
|||
|
|
|
|||
|
|
if not isinstance(ret, dict):
|
|||
|
|
logging.debug('check_task_status_new failed: {}'.format(ret))
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
with self.workflow.query('schedules') as query:
|
|||
|
|
query.where_in('id', list(schedule_ids)).update({
|
|||
|
|
'status': ScheduleNode.STATUS_COMPLETED,
|
|||
|
|
'update_time': cur_time,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
def sync_maillog_stat(self):
|
|||
|
|
logging.debug('sync automation-{} maillog statistics'.format(self.automation_id))
|
|||
|
|
|
|||
|
|
cur_time = int(time.time())
|
|||
|
|
|
|||
|
|
with self.automation.query() as query:
|
|||
|
|
last_stat_time = query.where('id', self.automation_id).value('case when `last_stat_time` > 0 then `last_stat_time` else `create_time` end as `last_stat_time`')
|
|||
|
|
|
|||
|
|
start_time = int(last_stat_time)
|
|||
|
|
end_time = cur_time
|
|||
|
|
msgids = set()
|
|||
|
|
|
|||
|
|
msgid_sent_file = '{}/data/mail/in_bulk/recipient/sent_recipient/msgid_{}.sent'.format(public.get_panel_path(), self.automation_id)
|
|||
|
|
msgid_sent_set = set()
|
|||
|
|
|
|||
|
|
if os.path.exists(msgid_sent_file):
|
|||
|
|
with open(msgid_sent_file, 'r') as fp:
|
|||
|
|
for msgid in fp:
|
|||
|
|
msgid = msgid.strip().strip('<>')
|
|||
|
|
if msgid == '':
|
|||
|
|
continue
|
|||
|
|
msgid_sent_set.add(msgid)
|
|||
|
|
|
|||
|
|
msgid_opened_file = '{}/data/mail/in_bulk/recipient/sent_recipient/msgid_{}.opened'.format(public.get_panel_path(), self.automation_id)
|
|||
|
|
msgid_opened_set = set()
|
|||
|
|
|
|||
|
|
if os.path.exists(msgid_opened_file):
|
|||
|
|
with open(msgid_opened_file, 'r') as fp:
|
|||
|
|
for msgid in fp:
|
|||
|
|
msgid = msgid.strip().strip('<>')
|
|||
|
|
if msgid == '':
|
|||
|
|
continue
|
|||
|
|
msgid_opened_set.add(msgid)
|
|||
|
|
|
|||
|
|
msgid_clicked_file = '{}/data/mail/in_bulk/recipient/sent_recipient/msgid_{}.clicked'.format(public.get_panel_path(), self.automation_id)
|
|||
|
|
msgid_clicked_set = set()
|
|||
|
|
|
|||
|
|
if os.path.exists(msgid_clicked_file):
|
|||
|
|
with open(msgid_clicked_file, 'r') as fp:
|
|||
|
|
for msgid in fp:
|
|||
|
|
msgid = msgid.strip().strip('<>')
|
|||
|
|
if msgid == '':
|
|||
|
|
continue
|
|||
|
|
msgid_clicked_set.add(msgid)
|
|||
|
|
|
|||
|
|
for node in self.workflow.walk_node_with_database():
|
|||
|
|
if node.type not in (Node.TYPE_EMAIL,):
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
campaign_id = int(node.get_attribute('campaign_id', 0))
|
|||
|
|
msgid_file = '{}/data/mail/in_bulk/recipient/sent_recipient/msgid_{}.log'.format(public.get_panel_path(), campaign_id)
|
|||
|
|
|
|||
|
|
if campaign_id < 1 or not os.path.exists(msgid_file):
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
with open(msgid_file, 'r') as fp:
|
|||
|
|
for msgid in fp:
|
|||
|
|
msgid = msgid.strip().strip('<>')
|
|||
|
|
if msgid == '':
|
|||
|
|
continue
|
|||
|
|
msgids.add(msgid)
|
|||
|
|
|
|||
|
|
# Check if there's anything new to process
|
|||
|
|
not_sent = msgids - msgid_sent_set
|
|||
|
|
not_opened = msgids - msgid_opened_set
|
|||
|
|
not_clicked = msgids - msgid_clicked_set
|
|||
|
|
|
|||
|
|
if len(not_sent) + len(not_opened) + len(not_clicked) == 0:
|
|||
|
|
logging.debug('sync automation-{} maillog statistics >>> no data for update'.format(self.automation_id))
|
|||
|
|
with self.automation.query() as query:
|
|||
|
|
query.where('id', self.automation_id).update({
|
|||
|
|
'last_stat_time': int(last_maillog_time()),
|
|||
|
|
'update_time': cur_time,
|
|||
|
|
})
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
with self.automation.query() as query:
|
|||
|
|
automation_info = query.where('id', self.automation_id).field('sent', 'delivered', 'opened', 'clicked').find()
|
|||
|
|
|
|||
|
|
stat_data = {
|
|||
|
|
'sent': len(msgids),
|
|||
|
|
'delivered': int(automation_info['delivered']),
|
|||
|
|
'opened': int(automation_info['opened']),
|
|||
|
|
'clicked': int(automation_info['clicked']),
|
|||
|
|
'last_stat_time': int(last_maillog_time()),
|
|||
|
|
'update_time': cur_time,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if len(not_sent) > 0:
|
|||
|
|
with public.S('message_ids') as query:
|
|||
|
|
query.alias('mi').prefix('')
|
|||
|
|
query.inner_join('send_mails sm', 'mi.postfix_message_id=sm.postfix_message_id')
|
|||
|
|
query.where('sm.log_time > ?', start_time - 1)
|
|||
|
|
query.where('sm.log_time < ?', end_time + 1)
|
|||
|
|
query.where('sm.status', 'sent')
|
|||
|
|
query.where('sm.dsn like ?', '2.%')
|
|||
|
|
query.where_in('mi.message_id', list(not_sent))
|
|||
|
|
query.field('mi.message_id')
|
|||
|
|
|
|||
|
|
ret = query_maillog_with_time_section(query, start_time, end_time)
|
|||
|
|
|
|||
|
|
msgid_sent_add = set()
|
|||
|
|
|
|||
|
|
for item in ret:
|
|||
|
|
msgid_sent_add.add(item['message_id'])
|
|||
|
|
stat_data['delivered'] += int(item['message_id'] not in msgid_sent_set)
|
|||
|
|
|
|||
|
|
if len(msgid_sent_add) > 0:
|
|||
|
|
with open(msgid_sent_file, 'a') as fp:
|
|||
|
|
fp.write('{}\n'.format('\n'.join(msgid_sent_add)))
|
|||
|
|
|
|||
|
|
if len(not_opened) > 0:
|
|||
|
|
with public.S('opened') as query:
|
|||
|
|
query.alias('o').prefix('')
|
|||
|
|
query.where('o.log_time > ?', start_time - 1)
|
|||
|
|
query.where('o.log_time < ?', end_time + 1)
|
|||
|
|
query.where_in('o.message_id', list(not_opened))
|
|||
|
|
query.group('o.message_id')
|
|||
|
|
query.field('o.message_id')
|
|||
|
|
|
|||
|
|
ret = query_maillog_with_time_section(query, start_time, end_time)
|
|||
|
|
|
|||
|
|
msgid_opened_add = set()
|
|||
|
|
|
|||
|
|
for item in ret:
|
|||
|
|
msgid_opened_add.add(item['message_id'])
|
|||
|
|
stat_data['opened'] += int(item['message_id'] not in msgid_opened_set)
|
|||
|
|
|
|||
|
|
if len(msgid_opened_add) > 0:
|
|||
|
|
with open(msgid_opened_file, 'a') as fp:
|
|||
|
|
fp.write('{}\n'.format('\n'.join(msgid_opened_add)))
|
|||
|
|
|
|||
|
|
if len(not_clicked) > 0:
|
|||
|
|
with public.S('clicked') as query:
|
|||
|
|
query.alias('c').prefix('')
|
|||
|
|
query.where('c.log_time > ?', start_time - 1)
|
|||
|
|
query.where('c.log_time < ?', end_time + 1)
|
|||
|
|
query.where_in('c.message_id', list(not_clicked))
|
|||
|
|
query.group('c.message_id')
|
|||
|
|
query.field('c.message_id')
|
|||
|
|
|
|||
|
|
ret = query_maillog_with_time_section(query, start_time, end_time)
|
|||
|
|
|
|||
|
|
msgid_clicked_add = set()
|
|||
|
|
|
|||
|
|
for item in ret:
|
|||
|
|
msgid_clicked_add.add(item['message_id'])
|
|||
|
|
stat_data['clicked'] += int(item['message_id'] not in msgid_clicked_set)
|
|||
|
|
|
|||
|
|
if len(msgid_clicked_add) > 0:
|
|||
|
|
with open(msgid_clicked_file, 'a') as fp:
|
|||
|
|
fp.write('{}\n'.format('\n'.join(msgid_clicked_add)))
|
|||
|
|
|
|||
|
|
with self.automation.query() as query:
|
|||
|
|
query.where('id', self.automation_id).update(stat_data)
|
|||
|
|
|
|||
|
|
logging.debug('sync automation-{} maillog statistics >>> OK'.format(self.automation_id))
|
|||
|
|
|
|||
|
|
def __schedule_one(self, schedule_node: ScheduleNode, cur_time: int = 0):
|
|||
|
|
if int(schedule_node.status) == ScheduleNode.STATUS_BROKEN:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
if cur_time < 1:
|
|||
|
|
cur_time = int(time.time())
|
|||
|
|
|
|||
|
|
schedule_id = int(schedule_node.id)
|
|||
|
|
root_id = int(schedule_node.node_id)
|
|||
|
|
|
|||
|
|
for node in self.workflow.walk_node_for_schedule(root_id):
|
|||
|
|
# 处理当前节点
|
|||
|
|
if node.id == root_id:
|
|||
|
|
# 当前节点正在处理中,等待下一次调度
|
|||
|
|
if schedule_node.status == ScheduleNode.STATUS_PROCESSING:
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
# 当前节点已处理完成,可以跳转到下一流程
|
|||
|
|
if schedule_node.status == ScheduleNode.STATUS_COMPLETED:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if node.type == Node.TYPE_DELAY:
|
|||
|
|
logging.debug('schedule delay node - {} {} {}'.format(node.id, node.get_attribute('value', 0), node.get_attribute('unit', Node.DELAY_UNIT_SECONDS)))
|
|||
|
|
|
|||
|
|
# delay节点
|
|||
|
|
seconds = int(node.get_attribute('value', 0))
|
|||
|
|
|
|||
|
|
if node.get_attribute('unit', Node.DELAY_UNIT_SECONDS) == Node.DELAY_UNIT_MINUTES:
|
|||
|
|
seconds *= 60
|
|||
|
|
elif node.get_attribute('unit', Node.DELAY_UNIT_SECONDS) == Node.DELAY_UNIT_HOURS:
|
|||
|
|
seconds *= 3600
|
|||
|
|
elif node.get_attribute('unit', Node.DELAY_UNIT_SECONDS) == Node.DELAY_UNIT_DAYS:
|
|||
|
|
seconds *= 86400
|
|||
|
|
|
|||
|
|
# 当前任务未到可执行下一步的时间
|
|||
|
|
if schedule_node.update_time + seconds > cur_time:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# 更新Delay节点已完成
|
|||
|
|
with self.workflow.query('schedules') as query:
|
|||
|
|
query.where('id', schedule_id).update({
|
|||
|
|
'status': ScheduleNode.STATUS_COMPLETED,
|
|||
|
|
'update_time': cur_time,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
schedule_node.node_id = node.id
|
|||
|
|
schedule_node.status = ScheduleNode.STATUS_PROCESSING
|
|||
|
|
schedule_node.update_time = cur_time
|
|||
|
|
|
|||
|
|
# 更新当前任务正在进行节点
|
|||
|
|
if schedule_id == 0:
|
|||
|
|
with self.workflow.query('schedules') as query:
|
|||
|
|
schedule_id = query.insert(schedule_node.to_dict())
|
|||
|
|
else:
|
|||
|
|
with self.workflow.query('schedules') as query:
|
|||
|
|
query.where('id', schedule_id).update(schedule_node.to_dict())
|
|||
|
|
|
|||
|
|
logging.debug('schedule node: {} {} {}'.format(node.type, node.id, schedule_node.status))
|
|||
|
|
|
|||
|
|
# 处理子节点
|
|||
|
|
if node.type == Node.TYPE_CONDITION:
|
|||
|
|
# Condition节点
|
|||
|
|
condition_rules = ConditionRules(node.get_attribute('rules', []),
|
|||
|
|
node.get_attribute('logic_type', Node.CONDITION_LOGIC_AND))
|
|||
|
|
condition_matched = condition_rules.match()
|
|||
|
|
for sub_node in self.workflow.walk_node_for_schedule(node.id):
|
|||
|
|
if sub_node.id == node.id:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if condition_matched and node.get_attribute('condition_branch', '') == Node.CONDITION_BRANCH_YES:
|
|||
|
|
# Yes分支
|
|||
|
|
# 更新当前任务正在进行节点
|
|||
|
|
with self.workflow.query('schedules') as query:
|
|||
|
|
query.where('id', schedule_id).update({
|
|||
|
|
'node_id': sub_node.id,
|
|||
|
|
'status': ScheduleNode.STATUS_WAITING,
|
|||
|
|
'update_time': cur_time,
|
|||
|
|
})
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
if not condition_matched and node.get_attribute('condition_branch', '') == Node.CONDITION_BRANCH_NO:
|
|||
|
|
# No分支
|
|||
|
|
# 更新当前任务正在进行节点
|
|||
|
|
with self.workflow.query('schedules') as query:
|
|||
|
|
query.where('id', schedule_id).update({
|
|||
|
|
'node_id': sub_node.id,
|
|||
|
|
'status': ScheduleNode.STATUS_WAITING,
|
|||
|
|
'update_time': cur_time,
|
|||
|
|
})
|
|||
|
|
break
|
|||
|
|
break
|
|||
|
|
elif node.type == Node.TYPE_ABTEST:
|
|||
|
|
# TODO Abtest节点(留到下一次迭代)
|
|||
|
|
pass
|
|||
|
|
elif node.type == Node.TYPE_EMAIL:
|
|||
|
|
# Email节点
|
|||
|
|
if schedule_node.status in (ScheduleNode.STATUS_WAITING,):
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
with self.workflow.query('schedules') as query:
|
|||
|
|
query.where('id', schedule_id).update({
|
|||
|
|
'status': ScheduleNode.STATUS_WAITING,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
logging.debug('update Email node status to waiting')
|
|||
|
|
|
|||
|
|
# Task().send_email_with_campaign(node, schedule_node.subscriber_email)
|
|||
|
|
break
|
|||
|
|
elif node.type == Node.TYPE_WEBHOOK:
|
|||
|
|
# Webhook节点
|
|||
|
|
# 发送请求
|
|||
|
|
logging.debug('send request to {}'.format(node.get_attribute('url')))
|
|||
|
|
threading.Thread(target=public.HttpPost, args=(node.get_attribute('url'), {
|
|||
|
|
'secret': node.get_attribute('secret', ''),
|
|||
|
|
'subscriber': schedule_node.subscriber_email,
|
|||
|
|
})).start()
|
|||
|
|
elif node.type == Node.TYPE_ACTION:
|
|||
|
|
# Action节点
|
|||
|
|
if node.get_attribute('action', '') == Node.ACTION_ADD_TO_SUBSCRIBERS:
|
|||
|
|
# TODO 将订阅者邮箱添加到指定用户组中
|
|||
|
|
pass
|
|||
|
|
elif node.get_attribute('action', '') == Node.ACTION_REMOVE_FROM_SUBSCRIBERS:
|
|||
|
|
# TODO 将订阅者邮箱从指定用户组中移除
|
|||
|
|
pass
|
|||
|
|
elif node.get_attribute('action', '') == Node.ACTION_MARK_AS_UNSUBSCRIBE:
|
|||
|
|
# TODO 将订阅者邮箱标记为 “已退订”
|
|||
|
|
pass
|
|||
|
|
elif node.get_attribute('action', '') == Node.ACTION_MOVE_TO_STEP:
|
|||
|
|
if node.get_attribute('next_node_id', 0) > 0:
|
|||
|
|
# 更新当前任务正在进行的节点
|
|||
|
|
with self.workflow.query('schedules') as query:
|
|||
|
|
query.where('id', schedule_id).update({
|
|||
|
|
'node_id': node.get_attribute('next_node_id', 0),
|
|||
|
|
'status': ScheduleNode.STATUS_WAITING,
|
|||
|
|
'update_time': cur_time,
|
|||
|
|
})
|
|||
|
|
break
|
|||
|
|
elif node.type == Node.TYPE_DELAY:
|
|||
|
|
# Delay节点
|
|||
|
|
if schedule_node.status in (ScheduleNode.STATUS_WAITING,):
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
with self.workflow.query('schedules') as query:
|
|||
|
|
query.where('id', schedule_id).update({
|
|||
|
|
'status': ScheduleNode.STATUS_WAITING,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
logging.debug('update Delay node status to waiting')
|
|||
|
|
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
# 更新该阶段的处理已完成
|
|||
|
|
with self.workflow.query('schedules') as query:
|
|||
|
|
query.where('id', schedule_id).update({
|
|||
|
|
'status': ScheduleNode.STATUS_COMPLETED,
|
|||
|
|
'update_time': cur_time,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
def __schedule_all(self):
|
|||
|
|
with self.workflow.query('schedules') as query:
|
|||
|
|
schedule_nodes = query.order('id', 'desc') \
|
|||
|
|
.field('id', 'node_id', 'subscriber_email', 'status', 'update_time') \
|
|||
|
|
.select()
|
|||
|
|
|
|||
|
|
if len(schedule_nodes) == 0:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
for schedule_node in schedule_nodes:
|
|||
|
|
self.__schedule_one(ScheduleNode(schedule_node))
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Task:
|
|||
|
|
def __init__(self):
|
|||
|
|
self.__interval = 15
|
|||
|
|
self.__automation = Automation()
|
|||
|
|
|
|||
|
|
def set_email_campaign(self, node: Node) -> int:
|
|||
|
|
if node.type != Node.TYPE_EMAIL:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
cur_time = int(time.time())
|
|||
|
|
|
|||
|
|
with public.S('email_task', '/www/vmail/postfixadmin') as query:
|
|||
|
|
if node.get_attribute('campaign_id', 0) < 1 or not query.where('id', node.get_attribute('campaign_id', 0)).exists():
|
|||
|
|
task_id = query.insert({
|
|||
|
|
'task_name': node.get_attribute('name', node.get_attribute('subject', '')),
|
|||
|
|
'addresser': node.get_attribute('from', ''),
|
|||
|
|
'recipient_count': 0,
|
|||
|
|
'task_process': 0,
|
|||
|
|
'pause': 0,
|
|||
|
|
'temp_id': node.get_attribute('email_id', 0),
|
|||
|
|
'is_record': 0,
|
|||
|
|
'unsubscribe': int(node.get_attribute('track_unsubscribe', False)),
|
|||
|
|
'threads': 0,
|
|||
|
|
'created': cur_time,
|
|||
|
|
'modified': cur_time,
|
|||
|
|
'start_time': 0,
|
|||
|
|
'remark': '',
|
|||
|
|
'etypes': '',
|
|||
|
|
'recipient': '',
|
|||
|
|
'subject': node.get_attribute('subject', ''),
|
|||
|
|
'full_name': node.get_attribute('from_name', node.get_attribute('from', '')),
|
|||
|
|
'type': 1,
|
|||
|
|
})
|
|||
|
|
else:
|
|||
|
|
task_id = node.get_attribute('campaign_id', 0)
|
|||
|
|
query.where('id', node.get_attribute('campaign_id', 0)).update({
|
|||
|
|
'task_name': node.get_attribute('name', node.get_attribute('subject', '')),
|
|||
|
|
'addresser': node.get_attribute('from', ''),
|
|||
|
|
'recipient_count': 0,
|
|||
|
|
'task_process': 0,
|
|||
|
|
'pause': 0,
|
|||
|
|
'temp_id': node.get_attribute('email_id', 0),
|
|||
|
|
'is_record': 0,
|
|||
|
|
'unsubscribe': int(node.get_attribute('track_unsubscribe', False)),
|
|||
|
|
'threads': 0,
|
|||
|
|
'created': cur_time,
|
|||
|
|
'modified': cur_time,
|
|||
|
|
'start_time': 0,
|
|||
|
|
'remark': '',
|
|||
|
|
'etypes': '',
|
|||
|
|
'recipient': '',
|
|||
|
|
'subject': node.get_attribute('subject', ''),
|
|||
|
|
'full_name': node.get_attribute('from_name', node.get_attribute('from', '')),
|
|||
|
|
'type': 1,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
db_dir = '/www/vmail/bulk'
|
|||
|
|
db_path = '{}/task_{}'.format(db_dir, task_id)
|
|||
|
|
|
|||
|
|
if not os.path.exists(db_dir):
|
|||
|
|
os.makedirs(db_dir)
|
|||
|
|
os.system('chown -R vmail:mail /www/vmail/bulk')
|
|||
|
|
|
|||
|
|
# 建表
|
|||
|
|
# 全量统计 message_id与收件人联合唯一
|
|||
|
|
sql = '''CREATE TABLE IF NOT EXISTS `task_count` (
|
|||
|
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
`queue_id` varchar(320) NOT NULL, -- 邮件队列id
|
|||
|
|
`message_id` TEXT NOT NULL, -- 邮件 message_id
|
|||
|
|
`created` INTEGER NOT NULL, -- 邮件时间 时间戳
|
|||
|
|
`recipient` varchar(320) NOT NULL, -- 收件人
|
|||
|
|
`delay` varchar(320) NOT NULL, -- 延时
|
|||
|
|
`delays` varchar(320) NOT NULL, -- 各阶段延时
|
|||
|
|
`dsn` varchar(320) NOT NULL, -- dsn
|
|||
|
|
`relay` text NOT NULL, -- 中继服务器
|
|||
|
|
`domain` varchar(320) NOT NULL, -- 域名
|
|||
|
|
`status` varchar(255) NOT NULL, -- 状态
|
|||
|
|
`code` INTEGER, -- 状态码 250 5xx 101
|
|||
|
|
`err_info` text NOT NULL, -- 详情
|
|||
|
|
UNIQUE(message_id,recipient)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
CREATE INDEX IF NOT EXISTS `message_id_recipient_index` ON `task_count` (`message_id`, `recipient`, `queue_id`);
|
|||
|
|
CREATE INDEX IF NOT EXISTS `id_status_index` ON `task_count` (`id`, `status`);
|
|||
|
|
|
|||
|
|
CREATE TABLE IF NOT EXISTS `recipient_info` (
|
|||
|
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
`recipient` varchar(320) NOT NULL, -- 收件人
|
|||
|
|
`is_sent` tinyint(1) NOT NULL DEFAULT 0, -- 是否发送
|
|||
|
|
`mail_provider` varchar(320) NOT NULL, -- 邮件提供商域名
|
|||
|
|
`sent_time` INTEGER NOT NULL, -- 发送时间
|
|||
|
|
`created` INTEGER NOT NULL, -- 添加时间
|
|||
|
|
UNIQUE(recipient)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
create index if not exists `recipient_isSent` on `recipient_info` (`recipient`, `is_sent`);'''
|
|||
|
|
|
|||
|
|
with public.S(db_name=db_path) as query:
|
|||
|
|
query.execute_script(sql)
|
|||
|
|
|
|||
|
|
return task_id
|
|||
|
|
|
|||
|
|
def send_email_with_campaign(self, node: Node, to: str) -> bool:
|
|||
|
|
campaign_id = node.get_attribute('campaign_id', 0)
|
|||
|
|
|
|||
|
|
if campaign_id < 1:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
with public.S('email_task', '/www/vmail/postfixadmin') as query:
|
|||
|
|
campaign = query.where('id', campaign_id).where('type', 1).field('task_process').find()
|
|||
|
|
|
|||
|
|
if campaign is None or int(campaign['task_process']) == 1:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
recipient_file = '{}/data/mail/in_bulk/recipient/{}_verify_{}'.format(public.get_panel_path(),
|
|||
|
|
public.Md5(str(node.id)),
|
|||
|
|
campaign_id)
|
|||
|
|
|
|||
|
|
recipient_dict = {
|
|||
|
|
'gmail.com': {"count": 0, "info": [
|
|||
|
|
to,
|
|||
|
|
]},
|
|||
|
|
'googlemail.com': {"count": 0, "info": []},
|
|||
|
|
'hotmail.com': {"count": 0, "info": []},
|
|||
|
|
'outlook.com': {"count": 0, "info": []},
|
|||
|
|
'yahoo.com': {"count": 0, "info": []},
|
|||
|
|
'icloud.com': {"count": 0, "info": []},
|
|||
|
|
'other': {"count": 0, "info": []},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
with open(recipient_file, 'w') as fp:
|
|||
|
|
fp.write(json.dumps(recipient_dict))
|
|||
|
|
|
|||
|
|
query.where('id', campaign_id).update({
|
|||
|
|
'task_process': 0,
|
|||
|
|
'recipient': recipient_file,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
ret = public.run_plugin('mail_sys', 'check_task_status_new', public.to_dict_obj({
|
|||
|
|
'task_id': campaign_id,
|
|||
|
|
}))
|
|||
|
|
|
|||
|
|
logging.debug(ret)
|
|||
|
|
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
def walk_running_tasks(self, include_inactive=False):
|
|||
|
|
status_lst = [1]
|
|||
|
|
|
|||
|
|
if include_inactive:
|
|||
|
|
status_lst.append(0)
|
|||
|
|
|
|||
|
|
with self.__automation.query() as query:
|
|||
|
|
tasks = query.where_in('status', status_lst).field('id', 'triggers').select()
|
|||
|
|
|
|||
|
|
for task in tasks:
|
|||
|
|
try:
|
|||
|
|
triggers = Triggers(json.loads(task['triggers']))
|
|||
|
|
except:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
yield int(task['id']), triggers
|
|||
|
|
|
|||
|
|
def schedule_async(self, automation_id: int, subscriber: typing.Union[str, typing.List[str]]):
|
|||
|
|
Scheduler(automation_id).schedule_async(subscriber)
|
|||
|
|
|
|||
|
|
def schedule(self, automation_id: int, subscriber: typing.Optional[str] = None):
|
|||
|
|
Scheduler(automation_id).schedule(subscriber)
|
|||
|
|
|
|||
|
|
def schedule_once(self):
|
|||
|
|
if self.__automation.is_init():
|
|||
|
|
try:
|
|||
|
|
for automation_id, _ in self.walk_running_tasks():
|
|||
|
|
logging.debug('schedule mail automation -- {}'.format(automation_id))
|
|||
|
|
scheduler = Scheduler(automation_id)
|
|||
|
|
scheduler.schedule()
|
|||
|
|
scheduler.schedule_email_sending()
|
|||
|
|
scheduler.sync_maillog_stat()
|
|||
|
|
except:
|
|||
|
|
public.print_error()
|
|||
|
|
|
|||
|
|
def schedule_forever(self):
|
|||
|
|
while True:
|
|||
|
|
time.sleep(self.__interval)
|
|||
|
|
|
|||
|
|
if self.__automation.is_init():
|
|||
|
|
try:
|
|||
|
|
for automation_id, _ in self.walk_running_tasks():
|
|||
|
|
logging.debug('schedule mail automation -- {}'.format(automation_id))
|
|||
|
|
scheduler = Scheduler(automation_id)
|
|||
|
|
scheduler.schedule()
|
|||
|
|
scheduler.schedule_email_sending()
|
|||
|
|
scheduler.sync_maillog_stat()
|
|||
|
|
except:
|
|||
|
|
public.print_error()
|
|||
|
|
# 发生未知错误时,短暂休眠以防循环过于频繁地失败
|
|||
|
|
time.sleep(60)
|
|||
|
|
|
|||
|
|
def fire(self, trigger_type: str, subscriber: typing.Union[str, typing.List[str]], group_ids: typing.Union[int, typing.List[int]] = 0):
|
|||
|
|
for automation_id, triggers in self.walk_running_tasks(include_inactive=True):
|
|||
|
|
if triggers.match(trigger_type, group_ids):
|
|||
|
|
try:
|
|||
|
|
self.schedule_async(automation_id, subscriber)
|
|||
|
|
except:
|
|||
|
|
public.print_log(public.get_error_info())
|
|||
|
|
|
|||
|
|
def subscriber_added(self, subscriber: typing.Union[str, typing.List[str]], group_ids: typing.Union[int, typing.List[int]] = 0):
|
|||
|
|
self.fire(Trigger.TYPE_SUBSCRIBER_ADDED, subscriber, group_ids)
|
|||
|
|
|
|||
|
|
# TODO 当订阅者通过API添加到用户组
|
|||
|
|
def subscribed(self, subscriber: str, group_ids: typing.Union[int, typing.List[int]] = 0):
|
|||
|
|
self.fire(Trigger.TYPE_SUBSCRIBED, subscriber, group_ids)
|
|||
|
|
|
|||
|
|
# TODO 当订阅者打开邮件
|
|||
|
|
def opened(self, subscriber: str, group_ids: typing.Union[int, typing.List[int]] = 0):
|
|||
|
|
self.fire(Trigger.TYPE_OPENED, subscriber, group_ids)
|
|||
|
|
|
|||
|
|
# TODO 当订阅者点击邮件链接
|
|||
|
|
def clicked(self, subscriber: str, group_ids: typing.Union[int, typing.List[int]] = 0):
|
|||
|
|
self.fire(Trigger.TYPE_CLICKED, subscriber, group_ids)
|
|||
|
|
|
|||
|
|
# TODO 当订阅者退订邮件
|
|||
|
|
def unsubscribed(self, subscriber: str, group_ids: typing.Union[int, typing.List[int]] = 0):
|
|||
|
|
self.fire(Trigger.TYPE_UNSUBSCRIBED, subscriber, group_ids)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def schedule_automations_forever():
|
|||
|
|
Task().schedule_forever()
|