checkworkmail.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. #!/usr/bin/env -S uv run --script
  2. #
  3. # /// script
  4. # dependencies = [
  5. # "requests",
  6. # "beautifulsoup4",
  7. # "imaplib2",
  8. # ]
  9. # ///
  10. import imaplib
  11. import email
  12. from email.header import decode_header
  13. from bs4 import BeautifulSoup
  14. import requests
  15. import time
  16. import os
  17. import re
  18. # ----------------------
  19. # CONFIGURATION
  20. # ----------------------
  21. GMAIL_USER = os.getenv("GMAIL_USER", "your-email@gmail.com")
  22. GMAIL_APP_PASSWORD = os.getenv("GMAIL_APP_PASSWORD", "your-app-password")
  23. NTFY_TOPIC = os.getenv("NTFY_TOPIC", "dev-notifications")
  24. NTFY_SERVER = os.getenv("NTFY_SERVER", "https://ntfy.sh")
  25. CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "60"))
  26. JIRA_SENDERS = ["jira@yourcompany.com", "jira@atlassian.net"]
  27. SLACK_SENDER = "notification@slack.com"
  28. GITHUB_SENDER = "notifications@github.com"
  29. JIRA_BASE_URL = os.getenv("JIRA_BASE_URL", "https://yourcompany.atlassian.net/browse")
  30. GH_STATE_AND_TITLE_TO_SKIP = ["New Relic Exporter", "cancelled", "skipped", "succeeded"]
  31. # ----------------------
  32. # HELPERS
  33. # ----------------------
  34. def sanitize_header(value: str) -> str:
  35. return re.sub(r'[\r\n]+', ' ', value).strip()
  36. def clean_subject(subject):
  37. decoded, encoding = decode_header(subject)[0]
  38. if isinstance(decoded, bytes):
  39. decoded = decoded.decode(encoding or 'utf-8', errors='ignore')
  40. return sanitize_header(decoded)
  41. def extract_body(msg):
  42. if msg.is_multipart():
  43. for part in msg.walk():
  44. if part.get_content_type() == "text/plain":
  45. return part.get_payload(decode=True).decode(errors="ignore")
  46. for part in msg.walk():
  47. if part.get_content_type() == "text/html":
  48. html = part.get_payload(decode=True).decode(errors="ignore")
  49. return BeautifulSoup(html, "html.parser").get_text()
  50. else:
  51. payload = msg.get_payload(decode=True).decode(errors="ignore")
  52. if msg.get_content_type() == "text/html":
  53. return BeautifulSoup(payload, "html.parser").get_text()
  54. return payload
  55. return "(No readable body content found)"
  56. def clean_slack_body(body):
  57. # Remove intro like: "Hi Colin,\n\nYou have a new direct message from ..."
  58. cleaned = re.sub(
  59. r"Hi .*?,\s+You have a new direct message from.*?\(.*?\.slack\.com.*?\)\.\s+---",
  60. "",
  61. body,
  62. flags=re.DOTALL,
  63. )
  64. return cleaned.strip()
  65. def clean_slack_body(body):
  66. # Remove intro like: "Hi Colin,\n\nYou have a new direct message from ..."
  67. cleaned = re.sub(
  68. r"Hi .*?,\s+You have a new direct message from.*?\(.*?\.slack\.com.*?\)\.\s+---",
  69. "",
  70. body,
  71. flags=re.DOTALL,
  72. )
  73. return cleaned.strip()
  74. def clean_and_relocate_slack_footer(body):
  75. # Match the "View in the archives" block
  76. archive_pattern = re.compile(
  77. r"(@\w+)?\s*View in the archives: (https://.*?\.slack\.com/\S+)", re.IGNORECASE
  78. )
  79. match = archive_pattern.search(body)
  80. if match:
  81. slack_url = match.group(2).strip()
  82. cleaned_body = archive_pattern.sub("", body).strip()
  83. # Format the link cleanly at the bottom
  84. footer = f"🔗 View in Slack: {slack_url}"
  85. return f"{cleaned_body}\n\n---\n{footer}"
  86. else:
  87. return body.strip()
  88. def extract_github_link(subject, body):
  89. pr_match = re.search(r'\(PR\s+#(\d+)\)', subject)
  90. repo_match = re.search(r'\[([^\]]+)\]', subject) # [owner/repo]
  91. if pr_match and repo_match:
  92. return f"https://github.com/{repo_match.group(1)}/pull/{pr_match.group(1)}"
  93. match = re.search(r'https://github\.com/\S+', body)
  94. return match.group(0) if match else None
  95. def extract_jira_link(subject):
  96. ticket_match = re.search(r'\b([A-Z]+-\d+)\b', subject)
  97. if ticket_match:
  98. return f"{JIRA_BASE_URL}/{ticket_match.group(1)}"
  99. return None
  100. def format_message(source, subject, body, link=None):
  101. return body.strip() or ""
  102. def send_ntfy_notification(title, message):
  103. url = f"{NTFY_SERVER.rstrip('/')}/{NTFY_TOPIC}"
  104. requests.post(url, data=message.encode("utf-8"), headers={"Title": sanitize_header(title)})
  105. def is_github_email(from_email):
  106. return GITHUB_SENDER in from_email.lower()
  107. def is_jira_email(from_email, subject):
  108. return any(s in from_email.lower() for s in JIRA_SENDERS) or re.search(r'[A-Z]+-\d+', subject)
  109. def is_slack_email(from_email, subject):
  110. return SLACK_SENDER in from_email.lower()
  111. def archive_message(mail, email_id):
  112. try:
  113. if isinstance(email_id, bytes):
  114. email_id = email_id.decode()
  115. mail.store(email_id, '+X-GM-LABELS', 'processed')
  116. except Exception as e:
  117. print(f"Error archiving message {email_id}: {e}")
  118. # ----------------------
  119. # MAIN LOOP
  120. # ----------------------
  121. def check_notifications():
  122. print("Checking inbox...")
  123. mail = imaplib.IMAP4_SSL("imap.gmail.com")
  124. mail.login(GMAIL_USER, GMAIL_APP_PASSWORD)
  125. mail.select("inbox")
  126. #result, data = mail.search(None, '(UNSEEN)')
  127. ok, data = mail.search(None, 'UNSEEN X-GM-LABELS', "inbox")
  128. #search_criteria = r'(UNSEEN X-GM-LABELS "\\Inbox")'
  129. #result, data = mail.uid('search', None, search_criteria)
  130. mail_ids = data[0].split()
  131. if not mail_ids:
  132. print("No new notifications.")
  133. mail.logout()
  134. return
  135. for num in mail_ids:
  136. result, msg_data = mail.fetch(num, '(RFC822)')
  137. raw_email = msg_data[0][1]
  138. msg = email.message_from_bytes(raw_email)
  139. subject = clean_subject(msg.get("Subject", "No Subject"))
  140. from_email = msg.get("From", "")
  141. body = extract_body(msg)
  142. if is_github_email(from_email):
  143. source = "GitHub"
  144. link = extract_github_link(subject, body)
  145. elif is_jira_email(from_email, subject):
  146. source = "Jira"
  147. link = extract_jira_link(subject)
  148. elif is_slack_email(from_email, subject):
  149. source = "Slack"
  150. link = ""
  151. body = clean_and_relocate_slack_footer(clean_slack_body(body))
  152. else:
  153. print(f"Skipping non-matching email: {subject}")
  154. continue
  155. message = format_message(source, subject, body, link)
  156. send_notice = True
  157. for exclusion in GH_STATE_AND_TITLE_TO_SKIP:
  158. if exclusion in subject:
  159. print(f"Skipping notification: {subject}")
  160. send_notice = False
  161. if send_notice:
  162. print(f"Sending {source} notification: {subject}")
  163. send_ntfy_notification(subject, message)
  164. archive_message(mail, num)
  165. mail.expunge()
  166. mail.logout()
  167. # ----------------------
  168. # RUNNER
  169. # ----------------------
  170. if __name__ == "__main__":
  171. while True:
  172. try:
  173. check_notifications()
  174. except Exception as e:
  175. print(f"Error: {e}")
  176. time.sleep(CHECK_INTERVAL)