cron.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. import argparse
  2. from dataclasses import dataclass
  3. from time import sleep
  4. import arrow
  5. from arrow import Arrow
  6. from app import s3
  7. from app.alias_utils import nb_email_log_for_mailbox
  8. from app.api.views.apple import verify_receipt
  9. from app.config import (
  10. IGNORED_EMAILS,
  11. ADMIN_EMAIL,
  12. MACAPP_APPLE_API_SECRET,
  13. APPLE_API_SECRET,
  14. EMAIL_SERVERS_WITH_PRIORITY,
  15. URL,
  16. AlERT_WRONG_MX_RECORD_CUSTOM_DOMAIN,
  17. )
  18. from app.dns_utils import get_mx_domains
  19. from app.email_utils import (
  20. send_email,
  21. send_trial_end_soon_email,
  22. render,
  23. email_can_be_used_as_mailbox,
  24. send_email_with_rate_control,
  25. )
  26. from app.extensions import db
  27. from app.log import LOG
  28. from app.models import (
  29. Subscription,
  30. User,
  31. Alias,
  32. EmailLog,
  33. CustomDomain,
  34. Client,
  35. ManualSubscription,
  36. RefusedEmail,
  37. AppleSubscription,
  38. Mailbox,
  39. Monitoring,
  40. Contact,
  41. )
  42. from server import create_app
  43. def notify_trial_end():
  44. for user in User.query.filter(
  45. User.activated == True, User.trial_end.isnot(None), User.lifetime == False
  46. ).all():
  47. if user.in_trial() and arrow.now().shift(
  48. days=3
  49. ) > user.trial_end >= arrow.now().shift(days=2):
  50. LOG.d("Send trial end email to user %s", user)
  51. send_trial_end_soon_email(user)
  52. def delete_refused_emails():
  53. for refused_email in RefusedEmail.query.filter(RefusedEmail.deleted == False).all():
  54. if arrow.now().shift(days=1) > refused_email.delete_at >= arrow.now():
  55. LOG.d("Delete refused email %s", refused_email)
  56. if refused_email.path:
  57. s3.delete(refused_email.path)
  58. s3.delete(refused_email.full_report_path)
  59. # do not set path and full_report_path to null
  60. # so we can check later that the files are indeed deleted
  61. refused_email.deleted = True
  62. db.session.commit()
  63. LOG.d("Finish delete_refused_emails")
  64. def notify_premium_end():
  65. """sent to user who has canceled their subscription and who has their subscription ending soon"""
  66. for sub in Subscription.query.filter(Subscription.cancelled == True).all():
  67. if (
  68. arrow.now().shift(days=3).date()
  69. > sub.next_bill_date
  70. >= arrow.now().shift(days=2).date()
  71. ):
  72. user = sub.user
  73. LOG.d(f"Send subscription ending soon email to user {user}")
  74. send_email(
  75. user.email,
  76. f"Your subscription will end soon {user.name}",
  77. render(
  78. "transactional/subscription-end.txt",
  79. user=user,
  80. next_bill_date=sub.next_bill_date.strftime("%Y-%m-%d"),
  81. ),
  82. render(
  83. "transactional/subscription-end.html",
  84. user=user,
  85. next_bill_date=sub.next_bill_date.strftime("%Y-%m-%d"),
  86. ),
  87. )
  88. def notify_manual_sub_end():
  89. for manual_sub in ManualSubscription.query.all():
  90. need_reminder = False
  91. if arrow.now().shift(days=14) > manual_sub.end_at > arrow.now().shift(days=13):
  92. need_reminder = True
  93. elif arrow.now().shift(days=4) > manual_sub.end_at > arrow.now().shift(days=3):
  94. need_reminder = True
  95. if need_reminder:
  96. user = manual_sub.user
  97. LOG.debug("Remind user %s that their manual sub is ending soon", user)
  98. send_email(
  99. user.email,
  100. f"Your trial will end soon {user.name}",
  101. render(
  102. "transactional/manual-subscription-end.txt",
  103. name=user.name,
  104. user=user,
  105. manual_sub=manual_sub,
  106. ),
  107. render(
  108. "transactional/manual-subscription-end.html",
  109. name=user.name,
  110. user=user,
  111. manual_sub=manual_sub,
  112. ),
  113. )
  114. def poll_apple_subscription():
  115. """Poll Apple API to update AppleSubscription"""
  116. # todo: only near the end of the subscription
  117. for apple_sub in AppleSubscription.query.all():
  118. user = apple_sub.user
  119. verify_receipt(apple_sub.receipt_data, user, APPLE_API_SECRET)
  120. verify_receipt(apple_sub.receipt_data, user, MACAPP_APPLE_API_SECRET)
  121. LOG.d("Finish poll_apple_subscription")
  122. @dataclass
  123. class Stats:
  124. nb_user: int
  125. nb_alias: int
  126. nb_forward: int
  127. nb_block: int
  128. nb_reply: int
  129. nb_bounced: int
  130. nb_spam: int
  131. nb_custom_domain: int
  132. nb_app: int
  133. nb_premium: int
  134. nb_apple_premium: int
  135. nb_cancelled_premium: int
  136. def stats_before(moment: Arrow) -> Stats:
  137. """return the stats before a specific moment, ignoring all stats come from users in IGNORED_EMAILS"""
  138. # nb user
  139. q = User.query
  140. for ie in IGNORED_EMAILS:
  141. q = q.filter(~User.email.contains(ie), User.created_at < moment)
  142. nb_user = q.count()
  143. LOG.d("total number user %s", nb_user)
  144. # nb alias
  145. q = db.session.query(Alias, User).filter(
  146. Alias.user_id == User.id, Alias.created_at < moment
  147. )
  148. for ie in IGNORED_EMAILS:
  149. q = q.filter(~User.email.contains(ie))
  150. nb_alias = q.count()
  151. LOG.d("total number alias %s", nb_alias)
  152. # email log stats
  153. q = (
  154. db.session.query(EmailLog)
  155. .join(User, EmailLog.user_id == User.id)
  156. .filter(
  157. EmailLog.created_at < moment,
  158. )
  159. )
  160. for ie in IGNORED_EMAILS:
  161. q = q.filter(~User.email.contains(ie))
  162. nb_spam = nb_bounced = nb_forward = nb_block = nb_reply = 0
  163. for email_log in q.yield_per(500):
  164. if email_log.bounced:
  165. nb_bounced += 1
  166. elif email_log.is_spam:
  167. nb_spam += 1
  168. elif email_log.is_reply:
  169. nb_reply += 1
  170. elif email_log.blocked:
  171. nb_block += 1
  172. else:
  173. nb_forward += 1
  174. LOG.d(
  175. "nb_forward %s, nb_block %s, nb_reply %s, nb_bounced %s, nb_spam %s",
  176. nb_forward,
  177. nb_block,
  178. nb_reply,
  179. nb_bounced,
  180. nb_spam,
  181. )
  182. nb_premium = Subscription.query.filter(
  183. Subscription.created_at < moment, Subscription.cancelled == False
  184. ).count()
  185. nb_apple_premium = AppleSubscription.query.filter(
  186. AppleSubscription.created_at < moment
  187. ).count()
  188. nb_cancelled_premium = Subscription.query.filter(
  189. Subscription.created_at < moment, Subscription.cancelled == True
  190. ).count()
  191. nb_custom_domain = CustomDomain.query.filter(
  192. CustomDomain.created_at < moment
  193. ).count()
  194. nb_app = Client.query.filter(Client.created_at < moment).count()
  195. data = locals()
  196. # to keep only Stats field
  197. data = {
  198. k: v
  199. for (k, v) in data.items()
  200. if k in vars(Stats)["__dataclass_fields__"].keys()
  201. }
  202. return Stats(**data)
  203. def increase_percent(old, new) -> str:
  204. if old == 0:
  205. return "N/A"
  206. increase = (new - old) / old * 100
  207. return f"{increase:.1f}%. Delta: {new-old}"
  208. def stats():
  209. """send admin stats everyday"""
  210. if not ADMIN_EMAIL:
  211. # nothing to do
  212. return
  213. stats_today = stats_before(arrow.now())
  214. stats_yesterday = stats_before(arrow.now().shift(days=-1))
  215. nb_user_increase = increase_percent(stats_yesterday.nb_user, stats_today.nb_user)
  216. nb_alias_increase = increase_percent(stats_yesterday.nb_alias, stats_today.nb_alias)
  217. nb_forward_increase = increase_percent(
  218. stats_yesterday.nb_forward, stats_today.nb_forward
  219. )
  220. today = arrow.now().format()
  221. send_email(
  222. ADMIN_EMAIL,
  223. subject=f"SimpleLogin Stats for {today}, {nb_user_increase} users, {nb_alias_increase} aliases, {nb_forward_increase} forwards",
  224. plaintext="",
  225. html=f"""
  226. Stats for {today} <br>
  227. nb_user: {stats_today.nb_user} - {increase_percent(stats_yesterday.nb_user, stats_today.nb_user)} <br>
  228. nb_premium: {stats_today.nb_premium} - {increase_percent(stats_yesterday.nb_premium, stats_today.nb_premium)} <br>
  229. nb_cancelled_premium: {stats_today.nb_cancelled_premium} - {increase_percent(stats_yesterday.nb_cancelled_premium, stats_today.nb_cancelled_premium)} <br>
  230. nb_apple_premium: {stats_today.nb_apple_premium} - {increase_percent(stats_yesterday.nb_apple_premium, stats_today.nb_apple_premium)} <br>
  231. nb_alias: {stats_today.nb_alias} - {increase_percent(stats_yesterday.nb_alias, stats_today.nb_alias)} <br>
  232. nb_forward: {stats_today.nb_forward} - {increase_percent(stats_yesterday.nb_forward, stats_today.nb_forward)} <br>
  233. nb_reply: {stats_today.nb_reply} - {increase_percent(stats_yesterday.nb_reply, stats_today.nb_reply)} <br>
  234. nb_block: {stats_today.nb_block} - {increase_percent(stats_yesterday.nb_block, stats_today.nb_block)} <br>
  235. nb_bounced: {stats_today.nb_bounced} - {increase_percent(stats_yesterday.nb_bounced, stats_today.nb_bounced)} <br>
  236. nb_spam: {stats_today.nb_spam} - {increase_percent(stats_yesterday.nb_spam, stats_today.nb_spam)} <br>
  237. nb_custom_domain: {stats_today.nb_custom_domain} - {increase_percent(stats_yesterday.nb_custom_domain, stats_today.nb_custom_domain)} <br>
  238. nb_app: {stats_today.nb_app} - {increase_percent(stats_yesterday.nb_app, stats_today.nb_app)} <br>
  239. """,
  240. )
  241. def sanity_check():
  242. """
  243. #TODO: investigate why DNS sometimes not working
  244. Different sanity checks
  245. - detect if there's mailbox that's using a invalid domain
  246. """
  247. mailbox_ids = (
  248. db.session.query(Mailbox.id)
  249. .filter(Mailbox.verified == True, Mailbox.disabled == False)
  250. .all()
  251. )
  252. mailbox_ids = [e[0] for e in mailbox_ids]
  253. # iterate over id instead of mailbox directly
  254. # as a mailbox can be deleted during the sleep time
  255. for mailbox_id in mailbox_ids:
  256. mailbox = Mailbox.get(mailbox_id)
  257. # a mailbox has been deleted
  258. if not mailbox:
  259. continue
  260. # hack to not query DNS too often
  261. sleep(1)
  262. if not email_can_be_used_as_mailbox(mailbox.email):
  263. mailbox.nb_failed_checks += 1
  264. nb_email_log = nb_email_log_for_mailbox(mailbox)
  265. # send a warning
  266. if mailbox.nb_failed_checks == 5:
  267. if mailbox.user.email != mailbox.email:
  268. send_email(
  269. mailbox.user.email,
  270. f"Mailbox {mailbox.email} is disabled",
  271. render(
  272. "transactional/disable-mailbox-warning.txt", mailbox=mailbox
  273. ),
  274. render(
  275. "transactional/disable-mailbox-warning.html",
  276. mailbox=mailbox,
  277. ),
  278. )
  279. # alert if too much fail and nb_email_log > 100
  280. if mailbox.nb_failed_checks > 10 and nb_email_log > 100:
  281. mailbox.disabled = True
  282. if mailbox.user.email != mailbox.email:
  283. send_email(
  284. mailbox.user.email,
  285. f"Mailbox {mailbox.email} is disabled",
  286. render("transactional/disable-mailbox.txt", mailbox=mailbox),
  287. render("transactional/disable-mailbox.html", mailbox=mailbox),
  288. )
  289. LOG.warning(
  290. "issue with mailbox %s domain. #alias %s, nb email log %s",
  291. mailbox,
  292. mailbox.nb_alias(),
  293. nb_email_log,
  294. )
  295. else: # reset nb check
  296. mailbox.nb_failed_checks = 0
  297. db.session.commit()
  298. for user in User.filter_by(activated=True).all():
  299. if user.email.lower().strip().replace(" ", "") != user.email:
  300. LOG.exception("%s does not have sanitized email", user)
  301. for alias in Alias.query.all():
  302. if alias.email.lower().strip().replace(" ", "") != alias.email:
  303. LOG.exception("Alias %s email not sanitized", alias)
  304. for contact in Contact.query.all():
  305. if contact.reply_email.lower().strip().replace(" ", "") != contact.reply_email:
  306. LOG.exception("Contact %s reply-email not sanitized", contact)
  307. for mailbox in Mailbox.query.all():
  308. if mailbox.email.lower().strip().replace(" ", "") != mailbox.email:
  309. LOG.exception("Mailbox %s address not sanitized", mailbox)
  310. LOG.d("Finish sanity check")
  311. def check_custom_domain():
  312. LOG.d("Check verified domain for DNS issues")
  313. for custom_domain in CustomDomain.query.filter(
  314. CustomDomain.verified == True
  315. ): # type: CustomDomain
  316. mx_domains = get_mx_domains(custom_domain.domain)
  317. if sorted(mx_domains) != sorted(EMAIL_SERVERS_WITH_PRIORITY):
  318. user = custom_domain.user
  319. LOG.warning(
  320. "The MX record is not correctly set for %s %s %s",
  321. custom_domain,
  322. user,
  323. mx_domains,
  324. )
  325. custom_domain.nb_failed_checks += 1
  326. # send alert if fail for 5 consecutive days
  327. if custom_domain.nb_failed_checks > 5:
  328. domain_dns_url = f"{URL}/dashboard/domains/{custom_domain.id}/dns"
  329. LOG.warning("Alert %s about %s", user, custom_domain)
  330. send_email_with_rate_control(
  331. user,
  332. AlERT_WRONG_MX_RECORD_CUSTOM_DOMAIN,
  333. user.email,
  334. f"Please update {custom_domain.domain} DNS on SimpleLogin",
  335. render(
  336. "transactional/custom-domain-dns-issue.txt",
  337. custom_domain=custom_domain,
  338. name=user.name or "",
  339. domain_dns_url=domain_dns_url,
  340. ),
  341. render(
  342. "transactional/custom-domain-dns-issue.html",
  343. custom_domain=custom_domain,
  344. name=user.name or "",
  345. domain_dns_url=domain_dns_url,
  346. ),
  347. max_nb_alert=1,
  348. nb_day=30,
  349. )
  350. # reset checks
  351. custom_domain.nb_failed_checks = 0
  352. else:
  353. # reset checks
  354. custom_domain.nb_failed_checks = 0
  355. db.session.commit()
  356. def delete_old_monitoring():
  357. """
  358. Delete old monitoring records
  359. """
  360. max_time = arrow.now().shift(days=-30)
  361. nb_row = Monitoring.query.filter(Monitoring.created_at < max_time).delete()
  362. db.session.commit()
  363. LOG.d("delete monitoring records older than %s, nb row %s", max_time, nb_row)
  364. if __name__ == "__main__":
  365. LOG.d("Start running cronjob")
  366. parser = argparse.ArgumentParser()
  367. parser.add_argument(
  368. "-j",
  369. "--job",
  370. help="Choose a cron job to run",
  371. type=str,
  372. choices=[
  373. "stats",
  374. "notify_trial_end",
  375. "notify_manual_subscription_end",
  376. "notify_premium_end",
  377. "delete_refused_emails",
  378. "poll_apple_subscription",
  379. "sanity_check",
  380. "delete_old_monitoring",
  381. "check_custom_domain",
  382. ],
  383. )
  384. args = parser.parse_args()
  385. app = create_app()
  386. with app.app_context():
  387. if args.job == "stats":
  388. LOG.d("Compute Stats")
  389. stats()
  390. elif args.job == "notify_trial_end":
  391. LOG.d("Notify users with trial ending soon")
  392. notify_trial_end()
  393. elif args.job == "notify_manual_subscription_end":
  394. LOG.d("Notify users with manual subscription ending soon")
  395. notify_manual_sub_end()
  396. elif args.job == "notify_premium_end":
  397. LOG.d("Notify users with premium ending soon")
  398. notify_premium_end()
  399. elif args.job == "delete_refused_emails":
  400. LOG.d("Deleted refused emails")
  401. delete_refused_emails()
  402. elif args.job == "poll_apple_subscription":
  403. LOG.d("Poll Apple Subscriptions")
  404. poll_apple_subscription()
  405. elif args.job == "sanity_check":
  406. LOG.d("Check data consistency")
  407. sanity_check()
  408. elif args.job == "delete_old_monitoring":
  409. LOG.d("Delete old monitoring records")
  410. delete_old_monitoring()
  411. elif args.job == "check_custom_domain":
  412. LOG.d("Check custom domain")
  413. check_custom_domain()