cron.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. import argparse
  2. from dataclasses import dataclass
  3. import arrow
  4. from arrow import Arrow
  5. from app import s3
  6. from app.api.views.apple import verify_receipt
  7. from app.config import (
  8. IGNORED_EMAILS,
  9. ADMIN_EMAIL,
  10. MACAPP_APPLE_API_SECRET,
  11. APPLE_API_SECRET,
  12. )
  13. from app.email_utils import (
  14. send_email,
  15. send_trial_end_soon_email,
  16. render,
  17. email_domain_can_be_used_as_mailbox,
  18. )
  19. from app.extensions import db
  20. from app.log import LOG
  21. from app.models import (
  22. Subscription,
  23. User,
  24. Alias,
  25. EmailLog,
  26. Contact,
  27. CustomDomain,
  28. Client,
  29. ManualSubscription,
  30. RefusedEmail,
  31. AppleSubscription,
  32. Mailbox,
  33. )
  34. from server import create_app
  35. def notify_trial_end():
  36. for user in User.query.filter(
  37. User.activated == True, User.trial_end.isnot(None), User.lifetime == False
  38. ).all():
  39. if user.in_trial() and arrow.now().shift(
  40. days=3
  41. ) > user.trial_end >= arrow.now().shift(days=2):
  42. LOG.d("Send trial end email to user %s", user)
  43. send_trial_end_soon_email(user)
  44. def delete_refused_emails():
  45. for refused_email in RefusedEmail.query.filter(RefusedEmail.deleted == False).all():
  46. if arrow.now().shift(days=1) > refused_email.delete_at >= arrow.now():
  47. LOG.d("Delete refused email %s", refused_email)
  48. if refused_email.path:
  49. s3.delete(refused_email.path)
  50. s3.delete(refused_email.full_report_path)
  51. # do not set path and full_report_path to null
  52. # so we can check later that the files are indeed deleted
  53. refused_email.deleted = True
  54. db.session.commit()
  55. LOG.d("Finish delete_refused_emails")
  56. def notify_premium_end():
  57. """sent to user who has canceled their subscription and who has their subscription ending soon"""
  58. for sub in Subscription.query.filter(Subscription.cancelled == True).all():
  59. if (
  60. arrow.now().shift(days=3).date()
  61. > sub.next_bill_date
  62. >= arrow.now().shift(days=2).date()
  63. ):
  64. user = sub.user
  65. LOG.d(f"Send subscription ending soon email to user {user}")
  66. send_email(
  67. user.email,
  68. f"Your subscription will end soon {user.name}",
  69. render(
  70. "transactional/subscription-end.txt",
  71. user=user,
  72. next_bill_date=sub.next_bill_date.strftime("%Y-%m-%d"),
  73. ),
  74. render(
  75. "transactional/subscription-end.html",
  76. user=user,
  77. next_bill_date=sub.next_bill_date.strftime("%Y-%m-%d"),
  78. ),
  79. )
  80. def notify_manual_sub_end():
  81. for manual_sub in ManualSubscription.query.all():
  82. need_reminder = False
  83. if arrow.now().shift(days=14) > manual_sub.end_at > arrow.now().shift(days=13):
  84. need_reminder = True
  85. elif arrow.now().shift(days=4) > manual_sub.end_at > arrow.now().shift(days=3):
  86. need_reminder = True
  87. if need_reminder:
  88. user = manual_sub.user
  89. LOG.debug("Remind user %s that their manual sub is ending soon", user)
  90. send_email(
  91. user.email,
  92. f"Your trial will end soon {user.name}",
  93. render(
  94. "transactional/manual-subscription-end.txt",
  95. name=user.name,
  96. user=user,
  97. manual_sub=manual_sub,
  98. ),
  99. render(
  100. "transactional/manual-subscription-end.html",
  101. name=user.name,
  102. user=user,
  103. manual_sub=manual_sub,
  104. ),
  105. )
  106. def poll_apple_subscription():
  107. """Poll Apple API to update AppleSubscription"""
  108. # todo: only near the end of the subscription
  109. for apple_sub in AppleSubscription.query.all():
  110. user = apple_sub.user
  111. verify_receipt(apple_sub.receipt_data, user, APPLE_API_SECRET)
  112. verify_receipt(apple_sub.receipt_data, user, MACAPP_APPLE_API_SECRET)
  113. LOG.d("Finish poll_apple_subscription")
  114. @dataclass
  115. class Stats:
  116. nb_user: int
  117. nb_alias: int
  118. nb_forward: int
  119. nb_block: int
  120. nb_reply: int
  121. nb_bounced: int
  122. nb_spam: int
  123. nb_custom_domain: int
  124. nb_app: int
  125. nb_premium: int
  126. def stats_before(moment: Arrow) -> Stats:
  127. """return the stats before a specific moment, ignoring all stats come from users in IGNORED_EMAILS
  128. """
  129. # nb user
  130. q = User.query
  131. for ie in IGNORED_EMAILS:
  132. q = q.filter(~User.email.contains(ie), User.created_at < moment)
  133. nb_user = q.count()
  134. LOG.d("total number user %s", nb_user)
  135. # nb alias
  136. q = db.session.query(Alias, User).filter(
  137. Alias.user_id == User.id, Alias.created_at < moment
  138. )
  139. for ie in IGNORED_EMAILS:
  140. q = q.filter(~User.email.contains(ie))
  141. nb_alias = q.count()
  142. LOG.d("total number alias %s", nb_alias)
  143. # email log stats
  144. q = (
  145. db.session.query(EmailLog)
  146. .join(User, EmailLog.user_id == User.id)
  147. .filter(EmailLog.created_at < moment,)
  148. )
  149. for ie in IGNORED_EMAILS:
  150. q = q.filter(~User.email.contains(ie))
  151. nb_spam = nb_bounced = nb_forward = nb_block = nb_reply = 0
  152. for email_log in q:
  153. if email_log.bounced:
  154. nb_bounced += 1
  155. elif email_log.is_spam:
  156. nb_spam += 1
  157. elif email_log.is_reply:
  158. nb_reply += 1
  159. elif email_log.blocked:
  160. nb_block += 1
  161. else:
  162. nb_forward += 1
  163. LOG.d(
  164. "nb_forward %s, nb_block %s, nb_reply %s, nb_bounced %s, nb_spam %s",
  165. nb_forward,
  166. nb_block,
  167. nb_reply,
  168. nb_bounced,
  169. nb_spam,
  170. )
  171. nb_premium = Subscription.query.filter(Subscription.created_at < moment).count()
  172. nb_premium += AppleSubscription.query.filter(
  173. AppleSubscription.created_at < moment
  174. ).count()
  175. nb_custom_domain = CustomDomain.query.filter(
  176. CustomDomain.created_at < moment
  177. ).count()
  178. nb_app = Client.query.filter(Client.created_at < moment).count()
  179. data = locals()
  180. # to keep only Stats field
  181. data = {
  182. k: v
  183. for (k, v) in data.items()
  184. if k in vars(Stats)["__dataclass_fields__"].keys()
  185. }
  186. return Stats(**data)
  187. def increase_percent(old, new) -> str:
  188. if old == 0:
  189. return "N/A"
  190. increase = (new - old) / old * 100
  191. return f"{increase:.1f}%"
  192. def stats():
  193. """send admin stats everyday"""
  194. if not ADMIN_EMAIL:
  195. # nothing to do
  196. return
  197. stats_today = stats_before(arrow.now())
  198. stats_yesterday = stats_before(arrow.now().shift(days=-1))
  199. nb_user_increase = increase_percent(stats_yesterday.nb_user, stats_today.nb_user)
  200. nb_alias_increase = increase_percent(stats_yesterday.nb_alias, stats_today.nb_alias)
  201. nb_forward_increase = increase_percent(
  202. stats_yesterday.nb_forward, stats_today.nb_forward
  203. )
  204. today = arrow.now().format()
  205. send_email(
  206. ADMIN_EMAIL,
  207. subject=f"SimpleLogin Stats for {today}, {nb_user_increase} users, {nb_alias_increase} aliases, {nb_forward_increase} forwards",
  208. plaintext="",
  209. html=f"""
  210. Stats for {today} <br>
  211. nb_user: {stats_today.nb_user} - {increase_percent(stats_yesterday.nb_user, stats_today.nb_user)} <br>
  212. nb_premium: {stats_today.nb_premium} - {increase_percent(stats_yesterday.nb_premium, stats_today.nb_premium)} <br>
  213. nb_alias: {stats_today.nb_alias} - {increase_percent(stats_yesterday.nb_alias, stats_today.nb_alias)} <br>
  214. nb_forward: {stats_today.nb_forward} - {increase_percent(stats_yesterday.nb_forward, stats_today.nb_forward)} <br>
  215. nb_reply: {stats_today.nb_reply} - {increase_percent(stats_yesterday.nb_reply, stats_today.nb_reply)} <br>
  216. nb_block: {stats_today.nb_block} - {increase_percent(stats_yesterday.nb_block, stats_today.nb_block)} <br>
  217. nb_bounced: {stats_today.nb_bounced} - {increase_percent(stats_yesterday.nb_bounced, stats_today.nb_bounced)} <br>
  218. nb_spam: {stats_today.nb_spam} - {increase_percent(stats_yesterday.nb_spam, stats_today.nb_spam)} <br>
  219. nb_custom_domain: {stats_today.nb_custom_domain} - {increase_percent(stats_yesterday.nb_custom_domain, stats_today.nb_custom_domain)} <br>
  220. nb_app: {stats_today.nb_app} - {increase_percent(stats_yesterday.nb_app, stats_today.nb_app)} <br>
  221. """,
  222. )
  223. def sanity_check():
  224. """Different sanity checks
  225. - detect if there's mailbox that's using a invalid domain
  226. """
  227. for mailbox in Mailbox.filter_by(verified=True).all():
  228. if not email_domain_can_be_used_as_mailbox(mailbox.email):
  229. LOG.error(
  230. "issue with mailbox %s domain. #alias %s, nb email log %s",
  231. mailbox,
  232. mailbox.nb_alias(),
  233. mailbox.nb_email_log(),
  234. )
  235. LOG.d("Disable mailbox and all its aliases")
  236. mailbox.verified = False
  237. for alias in mailbox.aliases:
  238. alias.enabled = False
  239. db.session.commit()
  240. email_msg = f"""Hi,
  241. Your mailbox {mailbox.email} cannot receive emails.
  242. To avoid forwarding emails to an invalid mailbox, we have disabled this mailbox along with all of its aliases.
  243. If this is a mistake, please reply to this email.
  244. Thanks,
  245. SimpleLogin team.
  246. """
  247. try:
  248. send_email(
  249. mailbox.user.email,
  250. f"{mailbox.email} is disabled",
  251. email_msg,
  252. email_msg.replace("\n", "<br>"),
  253. )
  254. except Exception:
  255. LOG.error("Cannot send disable mailbox email to %s", mailbox.user)
  256. LOG.d("Finish sanity check")
  257. if __name__ == "__main__":
  258. LOG.d("Start running cronjob")
  259. parser = argparse.ArgumentParser()
  260. parser.add_argument(
  261. "-j",
  262. "--job",
  263. help="Choose a cron job to run",
  264. type=str,
  265. choices=[
  266. "stats",
  267. "notify_trial_end",
  268. "notify_manual_subscription_end",
  269. "notify_premium_end",
  270. "delete_refused_emails",
  271. "poll_apple_subscription",
  272. "sanity_check",
  273. ],
  274. )
  275. args = parser.parse_args()
  276. app = create_app()
  277. with app.app_context():
  278. if args.job == "stats":
  279. LOG.d("Compute Stats")
  280. stats()
  281. elif args.job == "notify_trial_end":
  282. LOG.d("Notify users with trial ending soon")
  283. notify_trial_end()
  284. elif args.job == "notify_manual_subscription_end":
  285. LOG.d("Notify users with manual subscription ending soon")
  286. notify_manual_sub_end()
  287. elif args.job == "notify_premium_end":
  288. LOG.d("Notify users with premium ending soon")
  289. notify_premium_end()
  290. elif args.job == "delete_refused_emails":
  291. LOG.d("Deleted refused emails")
  292. delete_refused_emails()
  293. elif args.job == "poll_apple_subscription":
  294. LOG.d("Poll Apple Subscriptions")
  295. poll_apple_subscription()
  296. elif args.job == "sanity_check":
  297. LOG.d("Check data consistency")
  298. sanity_check()