Files
mailer/main.py
T

606 lines
23 KiB
Python

import argparse
import logging
import re
from selenium.common.exceptions import TimeoutException
from selenium.webdriver import Chrome, ChromeOptions
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as EC
from common.utils import *
from common.timer import Timer
from common.jsonrpc2 import ServiceProvider
from common.actionflow import Action, ActionFlow
from io import BytesIO
from enum import Enum
from wakepy import keep
from pathlib import Path
from datetime import datetime
from openpyxl import Workbook
from zoneinfo import ZoneInfo
from nameparser import HumanName
parser = argparse.ArgumentParser(description="Mailer")
parser.add_argument('account', type=str)
parser.add_argument('password', type=str)
parser.add_argument('-c', '--column', nargs='+', action='extend')
parser.add_argument('-d', '--directory', type=str, default=str(Path.home().joinpath('Downloads')))
parser.add_argument('-t', '--timeout', type=int, default=60)
parser.add_argument('-i', '--interval', type=int, default=10)
parser.add_argument('-r', '--attempts', type=int, default=3)
parser.add_argument('-l', '--log-level', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'])
args = parser.parse_args()
LOGIN = "https://id.ionos.fr/identifier"
WEBMAIL = "https://email.ionos.fr/appsuite/#app=io.ox/mail&mailto=%s"
def main(driver: Chrome, logger = logging.getLogger('main')):
timer = Timer()
parameters = vars(args)
sp = ServiceProvider(ServiceProvider.Options(application='Mailer'))
class Status(Enum):
IDLE = 0
READY = 1
RUNNING = 2
STANDBY = 3
CLOSING = 4
class ColumnMapping:
def __init__(self, email=None, recipient=None, code=None, region=None, sent=None, variables=None):
self.email = email or '邮箱'
self.recipient = recipient or '主要联系人'
self.code = code or '客户编号'
self.region = region or '国家地区'
self.sent = sent or '已发送'
self.variables = variables or '变量值'
status = Status.IDLE
options = dict()
headers = list()
progress = dict()
wb: Workbook = None
filename: str = None
column: ColumnMapping = None
limit = 0
def begin(opts: dict, args: dict):
nonlocal status
if status != Status.READY: raise ValueError(status)
options.update(opts)
status = Status.RUNNING
parameters.update(args)
timer.clear()
timer.start()
def pause():
nonlocal status
status = Status.STANDBY
timer.pause()
def resume():
nonlocal status
status = Status.RUNNING
driver.switch_to.window(driver.current_window_handle)
timer.start()
def unique(header: str):
result = dict()
col = headers.index(header) + 1
for row in range(options.get('start'), limit + 1):
key = str(wb.active.cell(row, col).value)
result[key] = result.get(key, 0) + 1
return result
sp.add(begin, pause, resume, unique)
sp.set('status', lambda: status.name)
sp.set('uptime', lambda: timer.delta())
try:
mapping = ColumnMapping(**{
k.lower().strip(): v.strip() for k, v in map(lambda o: str.split(o, '=', 2), parameters['column'] or list())
})
except Exception as e:
logger.critical('Unable to load column mappings', exc_info=e)
return 1
class Locale:
def __init__(self, name, timezone, default, morning=None, afternoon=None, evening=None, keywords=None):
self.name: str = name
self.timezone: str = timezone
self.default: str = default
self.morning: str = morning
self.afternoon: str = afternoon
self.evening: str = evening
self.keywords: list[str] = keywords
locales = [
Locale("en", "Europe/London", "Hello" , "Good morning", "Good afternoon" , "Good evening", []),
Locale("fr", "Europe/Paris" , "Bonjour", None , None , "Bonsoir" , ['法国', '比利时', '留尼汪']),
Locale("de", "Europe/Berlin", "Hallo" , "Guten Morgen", "Guten Tag" , "Guten Abend" , ['德国', '奥地利', '瑞士']),
Locale("it", "Europe/Rome" , "Ciao" , "Buongiorno" , "Buon pomeriggio", "Buonasera" , ['意大利']),
Locale("es", "Europe/Madrid", "Hola" , "Buenos días" , "Buenas tardes" , None , ['西班牙']),
Locale("pt", "Europe/Lisbon", "Olá" , "Bom dia" , "Boa tarde" , None , ['葡萄牙']),
]
try:
sp.set('context', lambda: { 'locales': list(map(vars, locales)), 'mapping': vars(mapping), 'parameters': parameters })
driver.get(sp.run())
except Exception as e:
logger.critical('Unable to load starup page', exc_info=e)
return 2
def contains_non_latin_alphabet(string: str):
from unicodedata import category, name
for char in string:
if char.isdigit() or (category(char).startswith('L') and not name(char, '').startswith('LATIN')):
return True
return False
try:
driver.switch_to.new_window('tab')
driver.set_page_load_timeout(parameters['timeout'])
driver.get(LOGIN)
except TimeoutException:
logger.warning('Timeout')
driver.execute_script("window.stop();")
try:
setup(driver, parameters)
click("#selectAll")
except Exception as e:
logger.debug('Can I haz cheez burger? :3', exc_info=e)
try:
logger.info('Logging in as %s', parameters['account'])
username = locate("#username")
username.send_keys(parameters['account'])
click("#button--with-loader")
password = locate("#password")
password.send_keys(parameters['password'])
click("#button--with-loader")
except Exception as e:
logger.critical('Error while logging in to %s', LOGIN, exc_info=e)
return 3
while True:
try:
loader = locate("#background-loader", wait=False)
if not loader.is_displayed(): break
except:
sleep(1)
def get_subject():
try:
element = locate("h1.subject", wait=False)
subject = element.text.strip()
return subject
except:
return None
def get_address():
try:
element = locate("header div.from", wait=False)
address = re.search(r'[^<\s]+@[^>\s]+', element.text)[0]
return address
except:
return None
def cell(row: int, header: str):
result = wb.active.cell(row, headers.index(header) + 1)
return result
def busy(driver: Chrome):
result = driver.find_elements(By.CSS_SELECTOR, ".io-ox-busy")
return result
try:
click("li[data-id='default0/Brouillons']", condition=EC.presence_of_element_located)
click("button[data-id='default0/Brouillons']", condition=EC.presence_of_element_located)
click("ul[aria-label='List view'] li[data-index='0']", condition=EC.presence_of_element_located)
logger.info('Done')
except Exception as e:
logger.warning("Could not open drafts; this may cause issues", exc_info=e)
finally:
driver.switch_to.window(driver.window_handles[0])
class Wait(Action):
@classmethod
def prepare(cls):
return True
@classmethod
def perform(cls):
if status == Status.RUNNING:
timer.start()
return False
timer.pause()
sleep(0.2)
return True
class Timeout(Action):
clock = Timer()
@classmethod
def prepare(cls):
cls.clock.clear()
cls.clock.start()
return True
@classmethod
def perform(cls):
if cls.clock.delta() > parameters['timeout']:
raise cls
sleep(0.2)
return True
class Acknowledge(Action):
@classmethod
def prepare(cls):
return status == Status.RUNNING
class Cancel(Action):
@classmethod
def prepare(cls):
nonlocal status
status = Status.RUNNING
return True
@classmethod
def perform(cls):
driver.switch_to.window(driver.window_handles[0])
raise cls
class Close(Action):
@classmethod
def prepare(cls):
return status == Status.CLOSING
class Skip(Action):
@classmethod
def prepare(cls):
return True
@classmethod
def perform(cls):
driver.switch_to.window(driver.current_window_handle)
raise cls
flow = ActionFlow()
flow.stage(Wait, Acknowledge, Timeout, Cancel, Close, Skip)
sp.add(pairs=[ (k.lower(), v) for k, v in flow ])
sp.set('actions', lambda: flow.capabilities())
def ready(name: str, b64: str):
from base64 import b64decode
from openpyxl import load_workbook
nonlocal status, filename, wb, limit
if status != Status.IDLE: raise ValueError(status)
data = b64decode(b64)
logger.info('Received %s byte(s) total', len(data))
logger.info('Loading...')
filename = name
buffer = BytesIO(data)
wb = load_workbook(buffer)
limit = wb.active.max_row
headers.clear()
for col in range(1, wb.active.max_column + 1):
value = str(wb.active.cell(1, col).value)
headers.append(value)
logger.info('Done')
flow.allow(Cancel)
status = Status.READY
return limit
def merge(b64: str):
from base64 import b64decode, b64encode
from openpyxl import load_workbook
if status != Status.CLOSING: raise ValueError(status)
data = b64decode(b64)
index = headers.index(column.sent)
buffer = BytesIO(data)
target = load_workbook(buffer)
for row in range(options.get('start'), limit + 1):
c1 = wb.active.cell(row, index + 1)
c2 = target.active.cell(row, index + 1)
if not (c1.value and str(c1.value).strip()):
if (c2.value and str(c2.value).strip()):
c1.value = c2.value
io = BytesIO()
wb.save(io)
result = b64encode(io.getbuffer()).decode('ascii')
return result
sp.add(ready, merge)
sp.set('progress', lambda: progress)
def save():
try:
file = Path(parameters['directory']).joinpath(datetime.now().strftime('Mailer-%Y%m%d-%H%M%S-%f.xlsx')).resolve()
logger.info('Saving document at %s', str(file))
wb.save(file)
except Exception as e:
logger.error('Error while writing data', exc_info=e)
exit = sp.pop('exit')
sp.set('exit', lambda: (save() if options.get('save') and status.value >= 2 else None, exit()))
class MismatchedEmailSubject(Exception): pass
class MismatchedEmailAddress(Exception): pass
class Next(Exception): pass
while True:
try:
flow.react(Wait)
progress.clear()
driver.switch_to.window(driver.window_handles[1])
subject = get_subject()
column = ColumnMapping(**options.get('mapping'))
if column.email not in headers or limit < 2:
logger.error("Column '%s' is not found or does not contain valid data", column.email)
raise Cancel()
if column.sent not in headers:
wb.active.cell(1, wb.active.max_column + 1).value = column.sent
headers.append(column.sent)
logger.info('Read %s line(s) total', limit)
logger.info('Subject: %s', subject)
logger.info('From: %s', parameters['account'])
locale: Locale = next(filter(lambda o: o.name == options.get('locale'), locales))
logger.info('Locale: %s', locale.name.upper())
logger.info('Timezone: %s', locale.timezone)
progress['done'] = 0
progress['skip'] = 0
progress['next'] = 0
progress['subject'] = subject
logger.info('Done')
except Cancel:
status = Status.IDLE
driver.switch_to.window(driver.window_handles[0])
continue
except Exception as e:
status = Status.IDLE
logger.error('Error while loading data', exc_info=e)
continue
index = 2
attempts = 0
tz = ZoneInfo(locale.timezone)
occurrence = dict()
while index <= limit:
try:
flow.react(Wait)
attempts += 1
try: code = str(cell(index, column.code).value)
except ValueError: code = None
occurrence.setdefault(code, 0)
if (target := cell(index, column.email).value) is None or not (email := str(target).strip()):
logger.warning("[%d/%d] Not an email address; skipping", index-1, limit-1)
raise Next()
if attempts > parameters['attempts']:
cell(index, column.sent).value = '❌'
logger.warning("[%d/%d] Exhausted all allowed attempts; skipping", index-1, limit-1)
raise Skip()
if options.get('slice') and (start := options.get('start')) and (end := options.get('limit')):
if index < start or index >= start + end:
logger.info("[%d/%d] Not planned; skipping '%s'", index-1, limit-1, email)
raise Next()
if (items := options.get('subcategories')) and (item := str(cell(index, column.region).value)) not in items:
logger.info("[%d/%d] Value '%s' not enlisted; skipping '%s'", index-1, limit-1, item, email)
raise Next()
if (sent := cell(index, column.sent).value) is not None and str(sent).strip():
logger.info("[%d/%d] Already visited; skipping '%s'", index-1, limit-1, email)
occurrence[code] += 1
raise Skip()
if (caps := options.get('occurrence')) is not None and caps > 0:
if code is None:
logger.error("Column '%s' cannot be used for spam control", column.code)
raise Cancel()
if occurrence[code] >= caps:
logger.info("[%d/%d] No Spam! Skipping '%s'", index-1, limit-1, email)
raise Skip()
while mails := driver.find_elements(By.CSS_SELECTOR, "div.io-ox-mail-compose-window"):
try:
click(mails[0].find_element(By.CSS_SELECTOR, "button[data-action='close']"), wait=False)
click("div.modal-footer button[data-action='delete']", wait=False)
except Exception as e:
logger.debug('Unable to close off email compose windows', exc_info=e)
break
flow.allow(Skip, Cancel)
flow.react(Wait)
progress['email'] = str(email)
logger.info('[%d/%d] Sending to %s', index-1, limit-1, email)
clean = True
error = None
if (target := get_address()) != parameters['account']:
error = MismatchedEmailAddress(target)
if (target := get_subject()) != subject:
error = MismatchedEmailSubject(target)
if error is not None and not options.get('force'):
raise error
flow.react(Wait)
click("ul.classic-toolbar button[aria-label='Edit copy']")
until(lambda x: busy(x))
until(lambda x: not busy(x))
locate("div.io-ox-mail-compose-window iframe", condition=EC.frame_to_be_available_and_switch_to_it)
if options.get('greet') and not (clean := False):
match datetime.now(tz).hour:
case h if 6 <= h < 12: hello = locale.morning
case h if 12 <= h < 18: hello = locale.afternoon
case h if 18 <= h < 21: hello = locale.evening
case _: hello = None
iframe = driver.switch_to.active_element
action = ActionChains(driver)
hello = hello or locale.default
if column.recipient in headers:
if name := str(cell(index, column.recipient).value).strip():
if not contains_non_latin_alphabet(name):
progress['recipient'] = str(name)
parts = HumanName(name)
parts.capitalize(force=True)
short = len(parts.first) < 3 or (len(parts.first) < 5 and parts.first.endswith('.'))
hello = ' '.join(filter(None, [hello, parts.title, parts.first, (parts.middle or parts.last) if short else None]))
hello += ','
action.send_keys(hello).perform()
if elements := iframe.find_elements(By.XPATH, f'//*[contains(text(), "{hello}")]'):
target = elements[0]
clean = target.text == hello
driver.switch_to.default_content()
flow.react(Wait)
click("div.io-ox-mail-compose-window div[data-extension-id='to'] > div.mail-input")
box = locate("div.io-ox-mail-compose-window div[data-extension-id='to'] > div.mail-input input.token-input.tt-input[tabindex='0']")
box.send_keys(str(email) + Keys.ENTER)
if column.variables in headers and (v := cell(index, column.variables).value) is not None:
clean = False
target = locate("div.io-ox-mail-compose-window div[data-extension-id='subject'] input")
template = target.get_attribute('value')
length = len(template)
for i, value in enumerate(str(v).split(',')):
template = template.replace('$$%s' % i, value.strip())
target.send_keys(Keys.BACKSPACE * length)
target.send_keys(template)
clean = target.get_attribute('value') == template
flow.react(Wait)
box = locate("div.io-ox-mail-compose-window .mail-input .tokenfield .token")
recipient = box.get_attribute('innerText').strip()
if recipient != str(email):
logger.warning('Malformed email address detected; retrying (%d)...', attempts)
continue
if not clean:
logger.warning('Malformed email content detected; retrying (%d)...', attempts)
continue
flow.react(Wait)
click("div.io-ox-mail-compose-window button[data-action='send']")
except Cancel:
break
except (Next, Skip) as e:
progress[e.__class__.__name__.lower()] += 1
index += 1
attempts = 0
continue
except (MismatchedEmailAddress, MismatchedEmailSubject) as e:
logger.error('Input mismatched', exc_info=e)
status = Status.STANDBY
continue
except Exception as e:
logger.error('Unexptected error', exc_info=e)
status = Status.STANDBY
continue
try:
alert = until(lambda x: x.find_element(By.CSS_SELECTOR, "div.io-ox-alert.io-ox-alert-error"), timeout=parameters['interval'])
message = alert.text.replace('\n', ' ')
click("div.io-ox-alert.io-ox-alert-error button[data-action='close']")
logger.warning('Error while sending email (message: %s); retrying (%d)...', message, attempts)
except TimeoutException:
cell(index, column.sent).value = '✔️'
progress['done'] += 1
occurrence[code] += 1
index += 1
attempts = 0
except Exception as e:
logger.error('Unexptected error', exc_info=e)
status = Status.STANDBY
continue
try:
logger.info('Waiting for client to acknowledge...')
flow.deter(Cancel, Skip, Wait)
flow.allow(Acknowledge)
flow.react(Timeout)
except Acknowledge:
pass
except Exception as e:
logger.error('RPC communication failed', exc_info=e)
sp.pop('exit')()
if options.get('save'):
try:
logger.info('Saving document...')
status = Status.CLOSING
flow.allow(Close)
flow.react(Timeout)
except Close:
pass
except Exception as e:
logger.warning('RPC communication failed', exc_info=e)
save()
flow.deter(Acknowledge, Timeout, Close)
driver.switch_to.window(driver.window_handles[0])
status = Status.IDLE
logger.info('Done')
if __name__ == '__main__':
try:
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s", datefmt="%Y-%m-%d %H:%M")
logger = logging.getLogger()
level = logging.getLevelNamesMapping().get(args.log_level, 'INFO')
logger.setLevel(level)
opts = ChromeOptions()
opts.add_experimental_option("prefs", {
"custom_handlers.enabled": False,
"custom_handlers.ignored_protocol_handlers": [{
"is_confirmed": True,
"last_modified": "13423573689616443",
"protocol": "mailto",
"security_level": 0,
"url": WEBMAIL,
}],
"profile.default_content_setting_values.notifications": 2,
"download.default_directory": args.directory,
})
with keep.presenting():
driver = Chrome(options=opts)
status = main(driver)
except KeyboardInterrupt:
status = 0
except Exception as e:
logger.critical('Fatal error', exc_info=e)
status = 1
finally:
driver.quit()
exit(status)