Skip to content

Commit

Permalink
Merge pull request #17 from bioimage-io/forward_emails
Browse files Browse the repository at this point in the history
Emails!
  • Loading branch information
FynnBe authored Mar 20, 2024
2 parents 8c9bc21 + c2df68a commit 33d8a9f
Show file tree
Hide file tree
Showing 10 changed files with 323 additions and 86 deletions.
78 changes: 0 additions & 78 deletions .github/workflows/email_call.yaml

This file was deleted.

27 changes: 27 additions & 0 deletions .github/workflows/emails_to_chat.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: forward emails to chat

on:
schedule:
- cron: "0 * * * *" # every hour at minute 0

concurrency: forward-emails-to-chat

env:
S3_HOST: ${{vars.S3_HOST}}
S3_BUCKET: ${{vars.S3_BUCKET}}
S3_FOLDER: ${{vars.S3_FOLDER}}
S3_ACCESS_KEY_ID: ${{secrets.S3_ACCESS_KEY_ID}}
S3_SECRET_ACCESS_KEY: ${{secrets.S3_SECRET_ACCESS_KEY}}
MAIL_PASSWORD: ${{secrets.MAIL_PASSWORD}}

jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
- run: pip install .
- run: backoffice forward-emails-to-chat
5 changes: 0 additions & 5 deletions .github/workflows/publish_call.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,3 @@ jobs:
cache: "pip" # caching pip dependencies
- run: pip install .
- run: backoffice publish "${{ inputs.resource_id }}" "staged/${{ inputs.stage_number }}"
# - name: Publish to Zenodo
# run: |
# python .github/scripts/update_status.py "${{ inputs.resource_path }}" "Publishing to Zenodo" "5"
# python .github/scripts/upload_model_to_zenodo.py --resource_path "${{inputs.resource_path}}"
# python .github/scripts/update_status.py "${{ inputs.resource_path }}" "Publishing complete" "6"
24 changes: 24 additions & 0 deletions backoffice/_backoffice.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from backoffice.backup import backup
from backoffice.generate_collection_json import generate_collection_json
from backoffice.gh_utils import set_gh_actions_outputs
from backoffice.mailroom import forward_emails_to_chat, notify_uploader
from backoffice.remote_resource import (
PublishedVersion,
RemoteResource,
Expand Down Expand Up @@ -90,6 +91,12 @@ def await_review(self, resource_id: str, version: str):
f"Cannot await review for already published {resource_id} {version}"
)
rv.await_review()
notify_uploader(
rv,
"is awaiting review ⌛",
f"Thank you for proposing {rv.id} {rv.version}!\n"
+ "Our maintainers will take a look shortly!",
)

def request_changes(self, resource_id: str, version: str, reason: str):
"""mark a (staged) resource version as needing changes"""
Expand All @@ -100,6 +107,13 @@ def request_changes(self, resource_id: str, version: str, reason: str):
)

rv.request_changes(reason=reason)
notify_uploader(
rv,
"needs changes 📑",
f"Thank you for proposing {rv.id} {rv.version}!\n"
+ "We kindly ask you to upload an updated version, because: \n"
+ f"{reason}\n",
)

def publish(self, resource_id: str, version: str):
"""publish a (staged) resource version"""
Expand All @@ -111,6 +125,13 @@ def publish(self, resource_id: str, version: str):

published: PublishedVersion = rv.publish()
assert isinstance(published, PublishedVersion)
self.generate_collection_json()
notify_uploader(
rv,
"was published! 🎉",
f"Thank you for contributing {published.id} {published.version} to bioimage.io!\n"
+ "Check it out at https://bioimage.io/#/?id={published.id}\n", # TODO: link to version
)

def backup(self, destination: Optional[str] = None):
"""backup the whole collection (to zenodo.org)"""
Expand All @@ -121,3 +142,6 @@ def generate_collection_json(
):
"""generate the collection.json file --- a summary of the whole collection"""
generate_collection_json(self.client, collection_template=collection_template)

def forward_emails_to_chat(self):
forward_emails_to_chat(self.client, last_n_days=7)
3 changes: 3 additions & 0 deletions backoffice/mailroom/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from ._forward_emails_to_chat import forward_emails_to_chat as forward_emails_to_chat
from ._send_email import notify_uploader as notify_uploader
from ._send_email import send_email as send_email
162 changes: 162 additions & 0 deletions backoffice/mailroom/_forward_emails_to_chat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import email.message
import email.parser
import imaplib
import os
from contextlib import contextmanager
from datetime import datetime, timedelta
from email.utils import parsedate_to_datetime
from typing import Any

from dotenv import load_dotenv
from loguru import logger

from backoffice.mailroom.constants import (
BOT_EMAIL,
IMAP_PORT,
REPLY_HINT,
SMTP_SERVER,
STATUS_UPDATE_SUBJECT,
)
from backoffice.remote_resource import get_remote_resource_version
from backoffice.s3_client import Client
from backoffice.s3_structure.chat import Chat, Message

_ = load_dotenv()


FORWARDED_TO_CHAT_FLAT = "forwarded-to-bioimageio-chat"


def forward_emails_to_chat(s3_client: Client, last_n_days: int):
cutoff_datetime = datetime.now().astimezone() - timedelta(days=last_n_days)
with _get_imap_client() as imap_client:
_update_chats(s3_client, imap_client, cutoff_datetime)


@contextmanager
def _get_imap_client():
imap_client = imaplib.IMAP4_SSL(SMTP_SERVER, IMAP_PORT)
_ = imap_client.login(BOT_EMAIL, os.environ["MAIL_PASSWORD"])
yield imap_client
_ = imap_client.logout()


def _get_body(msg: email.message.Message):
if msg.is_multipart():
for part in msg.walk():
ctype = part.get_content_type()
cdispo = str(part.get("Content-Disposition"))

# skip any text/plain (txt) attachments
if ctype == "text/plain" and "attachment" not in cdispo:
msg_part = part
break
else:
logger.error("faild to get body from multipart message: {}", msg)
return None
else:
# not multipart - i.e. plain text, no attachments, keeping fingers crossed
msg_part = msg

msg_bytes = msg_part.get_payload(decode=True)
try:
body = str(msg_bytes, "utf-8") # pyright: ignore[reportArgumentType]
except Exception as e:
logger.error("failed to decode email body: {}", e)
return None
else:
return body


def _update_chats(
s3_client: Client, imap_client: imaplib.IMAP4_SSL, cutoff_datetime: datetime
):
_ = imap_client.select("inbox")
for msg_id, rid, rv, msg, dt in _iterate_relevant_emails(
imap_client, cutoff_datetime
):
ok, flag_data = imap_client.fetch(str(msg_id), "(FLAGS)")
if ok != "OK" or len(flag_data) != 1:
logger.error("failed to get flags for {}", msg_id)
continue
try:
assert isinstance(flag_data[0], bytes), type(flag_data[0])
flags = str(flag_data[0], "utf-8")
except Exception as e:
logger.error("failed to interprete flags '{}': {}", flag_data[0], e)
continue

if FORWARDED_TO_CHAT_FLAT in flags:
continue # already processed

body = _get_body(msg)
if body is None:
continue

sender = msg["from"]
text = "[forwarded from email]\n" + body.replace("> " + REPLY_HINT, "").replace(
REPLY_HINT, ""
)
rr = get_remote_resource_version(s3_client, rid, rv)
if not rr.exists():
logger.error("Cannot comment on non-existing resource {} {}", rid, rv)
continue

rr.extend_chat(Chat(messages=[Message(author=sender, text=text, timestamp=dt)]))
_ = imap_client.store(str(msg_id), "+FLAGS", FORWARDED_TO_CHAT_FLAT)


def _iterate_relevant_emails(imap_client: imaplib.IMAP4_SSL, cutoff_datetime: datetime):
for msg_id, msg, dt in _iterate_emails(imap_client, cutoff_datetime):
subject = str(msg["subject"])
if STATUS_UPDATE_SUBJECT not in subject:
logger.debug("ignoring subject: '{}'", subject)
continue

try:
_, id_version = subject.split(STATUS_UPDATE_SUBJECT)
resource_id, resource_version = id_version.strip().split(" ")
except Exception:
logger.warning("failed to process subject: {}", subject)
continue

yield msg_id, resource_id, resource_version, msg, dt


def _iterate_emails(imap_client: imaplib.IMAP4_SSL, cutoff_datetime: datetime):
data = imap_client.search(None, "ALL")
mail_ids = data[1]
id_list = mail_ids[0].split()
first_email_id = int(id_list[0])
latest_email_id = int(id_list[-1])

for msg_id in range(latest_email_id, first_email_id, -1):
ok, msg_data = imap_client.fetch(str(msg_id), "(RFC822)")
if ok != "OK":
logger.error("failed to fetch email {}", msg_id)
continue

parts = [p for p in msg_data if isinstance(p, tuple)]
if len(parts) == 0:
logger.error("found email with without any parts")
continue
elif len(parts) > 1:
logger.error(
"found email with multiple parts. I'll only look at the first part"
)

_, msg_part = parts[0]

msg = email.message_from_string(str(msg_part, "utf-8"))
dt: Any = parsedate_to_datetime(msg["date"])
if isinstance(dt, datetime):
if dt < cutoff_datetime:
break
else:
logger.error("failed to parse email datetime '{}'", msg["date"])

yield msg_id, msg, dt


if __name__ == "__main__":
forward_emails_to_chat(Client(), 7)
57 changes: 57 additions & 0 deletions backoffice/mailroom/_send_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import os
import smtplib
from email.mime.text import MIMEText

from dotenv import load_dotenv
from loguru import logger

from backoffice.mailroom.constants import (
BOT_EMAIL,
REPLY_HINT,
SMTP_PORT,
SMTP_SERVER,
STATUS_UPDATE_SUBJECT,
)
from backoffice.remote_resource import PublishedVersion, StagedVersion

_ = load_dotenv()


def notify_uploader(rv: StagedVersion | PublishedVersion, subject_end: str, msg: str):
email, name = rv.get_uploader()
if email is None:
logger.error("missing uploader email for {} {}", rv.id, rv.version)
else:
send_email(
subject=f"{STATUS_UPDATE_SUBJECT}{rv.id} {rv.version} {subject_end.strip()}",
body=(
f"Dear {name},\n"
+ f"{msg.strip()}\n"
+ "Kind regards,\n"
+ "The bioimage.io bot 🦒\n"
+ REPLY_HINT
),
recipients=[email],
)


def send_email(subject: str, body: str, recipients: list[str]):
from_addr = BOT_EMAIL
to_addr = ", ".join(recipients)
msg = MIMEText(body)
msg["From"] = from_addr
msg["To"] = to_addr
msg["Subject"] = subject
with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT) as smtp_server:
_ = smtp_server.login(BOT_EMAIL, os.environ["MAIL_PASSWORD"])
_ = smtp_server.sendmail(BOT_EMAIL, recipients, msg.as_string())

logger.info("Email '{}' sent to {}", subject, recipients)


if __name__ == "__main__":
send_email(
subject=STATUS_UPDATE_SUBJECT + " lazy-bug staged/2",
body="Staged version 2 of your model 'lazy-bug' is now under review.",
recipients=["[email protected]"],
)
Loading

0 comments on commit 33d8a9f

Please sign in to comment.