diff --git a/rhodecode/lib/celerylib/tasks.py b/rhodecode/lib/celerylib/tasks.py index a5f5eaef..41a43bad 100644 --- a/rhodecode/lib/celerylib/tasks.py +++ b/rhodecode/lib/celerylib/tasks.py @@ -107,7 +107,43 @@ def send_email(recipients, subject, body="", html_body="", email_config=None, ex recipients += admins # translate our LEGACY config into the one that pyramid_mailer supports - transformed_email_confing = dict( + transformed_email_confing = transform_legacy_email_config(email_config, mail_server) + + if extra_headers is None: + extra_headers = {} + + extra_headers.setdefault("Date", formatdate(time.time())) + + if "thread_ids" in extra_headers: + thread_ids = extra_headers.pop("thread_ids") + extra_headers["References"] = " ".join(f"<{t}>" for t in thread_ids) + + try: + mailer = get_mailer(transformed_email_confing, email_config) + + message = Message( + subject=subject, + sender=transformed_email_confing["default_sender"], + recipients=recipients, + body=body, + html=html_body, + extra_headers=extra_headers, + ) + mailer.send_immediately(message) + + statsd = StatsdClient.statsd + if statsd: + statsd.incr("rhodecode_email_sent_total") + + except Exception: + log.exception("Mail sending failed") + return False + return True + + +def transform_legacy_email_config(email_config: dict[Any, Any] | Any, mail_server: Any | None) -> dict[ + str, None | int | bool | str | Any]: + return dict( host=mail_server, port=email_config.get("smtp_port", 25), username=email_config.get("smtp_username", None), @@ -128,42 +164,14 @@ def send_email(recipients, subject, body="", html_body="", email_config=None, ex # sendmail_template='', ) - if extra_headers is None: - extra_headers = {} - extra_headers.setdefault("Date", formatdate(time.time())) - - if "thread_ids" in extra_headers: - thread_ids = extra_headers.pop("thread_ids") - extra_headers["References"] = " ".join("<{}>".format(t) for t in thread_ids) - - try: - mailer = _get_mailer(transformed_email_confing, email_config) - - message = Message( - subject=subject, - sender=transformed_email_confing["default_sender"], - recipients=recipients, - body=body, - html=html_body, - extra_headers=extra_headers, - ) - mailer.send_immediately(message) - statsd = StatsdClient.statsd - if statsd: - statsd.incr("rhodecode_email_sent_total") - - except Exception: - log.exception("Mail sending failed") - return False - return True - - -def _get_mailer(transformed_email_conf: dict[str, Any], original_email_conf: dict[str, Any]) -> Mailer | DebugMailer: +def get_mailer(transformed_email_conf: dict[str, Any], original_email_conf: dict[str, Any]) -> Mailer | DebugMailer: + log = get_logger(get_mailer) dev_mode = str2bool(original_email_conf.get("development_email")) if dev_mode: emails_path = original_email_conf.get("local_email_store") or "./local_emails" + log.debug(f"Getting development email. emails folder path: {emails_path}") return DebugMailer(emails_path) return Mailer(**transformed_email_conf) diff --git a/rhodecode/lib/rc_commands/inactive_users.py b/rhodecode/lib/rc_commands/inactive_users.py new file mode 100644 index 00000000..b50aa6de --- /dev/null +++ b/rhodecode/lib/rc_commands/inactive_users.py @@ -0,0 +1,236 @@ +import csv +import decimal +import logging +import os +import time +from datetime import datetime, timezone, date +from email.utils import formatdate +from typing import List + +from sqlalchemy.inspection import inspect as sa_inspect +from pyramid_mailer.message import Message + +import rhodecode +from rhodecode.lib.celerylib.tasks import transform_legacy_email_config, get_mailer +from rhodecode.lib.statsd_client import StatsdClient +from rhodecode.lib.type_utils import str2bool +from rhodecode.model.db import User +from rhodecode.model.meta import Base +from rhodecode.model.user import UserModel + +log = logging.getLogger(__name__) +SENSITIVE_FIELDS = {"password", "api_key", "user_auth_tokens", "user_ssh_keys"} +EMAIL_SUBJECT = "Action needed: inactive account scheduled for deletion" +EMAIL_BODY_TEMPLATE = ('''Hi {first_name}, +We haven’t seen any activity on your account for for at least {days_inactive} days. +To keep your account, please sign in and use the service within the next {reactivate_days} days. +If no action is taken, your account will be permanently deleted. +Need help? https://rhodecode.com/support''') + + +INACTIVITY_DAYS = 183 +DAYS_FOR_REACTIVATION = 30 + + +def find_inactive_users(inactive_days: int = INACTIVITY_DAYS): + log.info(f"Find users who are inactive for {inactive_days} days") + inactive_users = User.list_inactive(older_then_days=inactive_days) + log.debug(f"Found {len(inactive_users)} inactive users. Inactive user names: {[u.username for u in inactive_users]}") + has_emails = [u for u in inactive_users if u.email] + no_emails = [u for u in inactive_users if not u.email] + return has_emails, no_emails + + +def dump_users_to_csv( + users: List[Base], + *, + model=None, + directory: str = ".", + filename_prefix: str = "inactive_users", + sensitive_fields: set[str] = SENSITIVE_FIELDS, +) -> str | None: + """ + Dump ORM user rows into a CSV. + - Auto-discovers mapped columns (no relations) via SQLAlchemy. + - Blanks sensitive fields. + - filename_prefix: to filename_prefix will be added UTC timestamp + .csv extension + Returns the absolute file path. + """ + # 1) Determine the model and column list (skip relationships by using mapper.columns) + if not users: + log.error("No users found, nothing to dump") + return None + + if not all(isinstance(u, Base) for u in users): + log.error(f"Provide SQLAlchemy model list") + return None + + if model is None: + model = type(users[0]) + + mapper = sa_inspect(model) + column_keys = [col.key for col in mapper.columns] + + # 2) Build filename with UTC timestamp + ts = datetime.now(timezone.utc) + stamp = f"{ts.strftime('%d-%m-%Y_%H:%M:%S')}" + filename = f"{filename_prefix}_{stamp}.csv" + + os.makedirs(directory, exist_ok=True) + filepath = os.path.abspath(os.path.join(directory, filename)) + + # 3) Simple serializer for CSV-safe values + def _ser(v): + if v is None: + return "" + if isinstance(v, (datetime, date)): + return v.isoformat(sep=" ", timespec="seconds") + if isinstance(v, decimal.Decimal): + return format(v, "f") + return str(v) + + sensitive = set(sensitive_fields) + + try: + with open(filepath, "w", newline="", encoding="utf-8") as f: + log.info(f"Writing user data for {len(users)} user(s) to {filepath}") + writer = csv.DictWriter(f, fieldnames=column_keys, extrasaction="ignore") + writer.writeheader() + + for u in users: + row = {} + for key in column_keys: + row[key] = "" if key in sensitive else _ser(getattr(u, key, None)) + writer.writerow(row) + except Exception as e: + log.error(f"Failed to write users data for {filepath}: {e}") + return None + + return filepath + + +def batch_send_email_to_inactive_users( + users: list[User], + subject: str = EMAIL_SUBJECT, + body_template: str = EMAIL_BODY_TEMPLATE, + inactive_days: int = INACTIVITY_DAYS, + days_for_reactivation: int = DAYS_FOR_REACTIVATION, +) -> tuple[list[str], list[str]]: + emails_sent = [] + emails_failed = [] + + if not users: + log.warning("No recipients provided") + return emails_sent, emails_failed + + email_config = rhodecode.CONFIG + + mail_server = email_config.get("smtp_server") or None + dev_mod = str2bool(email_config.get("development_email")) + + if mail_server is None and not dev_mod: + log.error( + "SMTP server information missing. Sending email failed. " + "Make sure that `smtp_server` variable is configured " + "inside the .ini file" + ) + return emails_sent, emails_failed + + conf_prefix = email_config.get("email_prefix", None) + prefix = f"{conf_prefix} " if conf_prefix else "" + subject = f"{prefix}{subject}" + transformed_email_confing = transform_legacy_email_config(email_config, mail_server) + extra_headers = { + "Date": formatdate(time.time()) + } + + mailer = get_mailer(transformed_email_confing, email_config) + + users_with_emails = list(filter(lambda u: u.email, users)) + + log.debug(f"Total emails to send: {len(users_with_emails)}") + + for u in users_with_emails: + try: + log.debug(f"Sending email to {u.email}") + body = body_template.format( + first_name=u.first_name, + days_inactive=inactive_days, + reactivate_days=days_for_reactivation + ) + + message = Message( + subject=subject, + sender=transformed_email_confing["default_sender"], + recipients=[u.email], + body=body, + extra_headers=extra_headers, + ) + mailer.send_immediately(message) + + statsd = StatsdClient.statsd + if statsd: + statsd.incr("rhodecode_email_sent_total") + + emails_sent.append(u) + + log.debug(f"Sent email to {u.email}") + except Exception as e: + log.error(f"Mail sending failed for recipient {u.email}", e) + emails_failed.append(u) + time.sleep(1) + + return emails_sent, emails_failed + + +def filter_reactivated_users(csv_path: str, inactive_days: int = INACTIVITY_DAYS) -> tuple[list[User], list[User]]: + """ + takes users from the csv file, and compare them with existing users if user have been reactivated + """ + inactive_users = [] + reactivated_users = [] + if not os.path.exists(csv_path) or not os.path.isfile(csv_path) or not os.access(csv_path, os.R_OK): + log.error(f"File {csv_path} does not exist or is not readable") + return inactive_users, reactivated_users + + ids = [] + with open(csv_path, "r", encoding="utf-8") as f: + reader = csv.DictReader(f, delimiter=",") + for row in reader: + if not row["user_id"]: + log.error("Can't find user_id in csv file, skipping line") + continue + ids.append(int(row["user_id"])) + + csv_users = UserModel().list(ids) + has_emails, no_emails = find_inactive_users(inactive_days) + current_inactive_users = [*has_emails, *no_emails] + current_inactive_users_ids = {u.user_id for u in current_inactive_users} + + for u in csv_users: + if u.user_id in current_inactive_users_ids: + inactive_users.append(u) + else: + reactivated_users.append(u) + + return inactive_users, reactivated_users + + +def delete_inactive_users_and_their_assets(users: List[User]) -> tuple[list[dict], list[dict]]: + deleted_users = [] + failed_to_delete = [] + for u in users: + log.debug(f"Deleting user {u.username}") + user_info = { + "id": u.user_id, + "username": u.username, + "email": u.email, + } + try: + UserModel().delete(u.user_id) + deleted_users.append(user_info) + except Exception as e: + log.error(f"Failed to delete user {u.username}: {e}") + failed_to_delete.append(user_info) + + return deleted_users, failed_to_delete diff --git a/rhodecode/model/db.py b/rhodecode/model/db.py index b71b41f7..be2c1cef 100644 --- a/rhodecode/model/db.py +++ b/rhodecode/model/db.py @@ -716,6 +716,34 @@ class User(Base, BaseModel): def __repr__(self): return f"<{self.cls_name}('id={self.user_id}, username={self.username}')>" + @classmethod + def list_inactive(cls, older_then_days: int): + query = cls.query() + + if not older_then_days or older_then_days <= 0: + log.warning(f"Older then the days cannot be zero or negative: {older_then_days}") + raise ValueError("Older then the days cannot be zero or negative") + + cutoff_date = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=older_then_days) + + never_logged_in = ((cls.last_login == None) & (cls.last_activity == None) & (cls.created_on < cutoff_date)) + last_activity_long_ago_or_no_activity_since_creation = ( + (cls.last_activity < cutoff_date) | ((cls.last_activity == None) & (cls.created_on < cutoff_date)) + ) + exclude_system_users = ~cls.username.in_([ + cls.GHOST_USER, cls.AI_USER, cls.DEFAULT_USER + ]) + + query = query.filter( + exclude_system_users, + or_( + never_logged_in, + last_activity_long_ago_or_no_activity_since_creation + ) + ) + + return query.all() + @hybrid_property def email(self): return self._email diff --git a/rhodecode/model/user.py b/rhodecode/model/user.py index c367b3ed..b303b50d 100644 --- a/rhodecode/model/user.py +++ b/rhodecode/model/user.py @@ -77,6 +77,14 @@ class UserModel(BaseModel): q = q.options(FromCache("sql_cache_short", f"get_users_{user_id}")) return cls.execute(q).scalar_one_or_none() + def list(self, user_ids: list[int]): + if not user_ids: + return [] + + cls = self.cls + q = cls.select().where(cls.user_id.in_(user_ids)) + return cls.execute(q).scalars().all() + def get_user(self, user): return self._get_user(user)