PageRenderTime 258ms CodeModel.GetById 17ms RepoModel.GetById 1ms app.codeStats 0ms

/timApp/messaging/messagelist/messagelist_utils.py

https://gitlab.com/tim-jyu/tim
Python | 1197 lines | 1104 code | 29 blank | 64 comment | 19 complexity | c8f16ea9dbfb7c6eb621a8010f5314ad MD5 | raw file
  1. import itertools
  2. import re
  3. from dataclasses import dataclass, field
  4. from datetime import datetime
  5. from email.utils import parsedate_to_datetime
  6. from enum import Enum
  7. from re import Match
  8. from typing import Iterator
  9. from urllib.error import HTTPError
  10. from urllib.parse import SplitResult, parse_qs, urlsplit
  11. from mailmanclient import MailingList
  12. from sqlalchemy.orm import load_only
  13. from timApp.auth.accesshelper import has_manage_access, AccessDenied
  14. from timApp.auth.accesstype import AccessType
  15. from timApp.auth.sessioninfo import get_current_user_object
  16. from timApp.document.create_item import create_document, apply_template
  17. from timApp.document.docentry import DocEntry
  18. from timApp.document.docinfo import DocInfo
  19. from timApp.folder.folder import Folder
  20. from timApp.item.block import Block
  21. from timApp.item.validation import ItemValidationRule
  22. from timApp.messaging.messagelist.emaillist import (
  23. get_email_list_by_name,
  24. set_notify_owner_on_list_change,
  25. set_email_list_unsubscription_policy,
  26. set_email_list_subject_prefix,
  27. set_email_list_only_text,
  28. set_email_list_allow_nonmember,
  29. set_email_list_allow_attachments,
  30. set_email_list_default_reply_type,
  31. add_email,
  32. get_email_list_member,
  33. remove_email_list_membership,
  34. set_email_list_member_send_status,
  35. set_email_list_member_delivery_status,
  36. set_email_list_description,
  37. set_email_list_info,
  38. log_mailman,
  39. )
  40. from timApp.messaging.messagelist.listinfo import (
  41. ArchiveType,
  42. ListInfo,
  43. ReplyToListChanges,
  44. )
  45. from timApp.messaging.messagelist.messagelist_models import (
  46. MessageListModel,
  47. Channel,
  48. MessageListTimMember,
  49. MessageListExternalMember,
  50. MessageListMember,
  51. )
  52. from timApp.timdb.sqa import db
  53. from timApp.user.groups import verify_groupadmin
  54. from timApp.user.user import User
  55. from timApp.user.usergroup import UserGroup
  56. from timApp.util.flask.requesthelper import RouteException
  57. from timApp.util.logger import log_warning
  58. from timApp.util.utils import remove_path_special_chars, get_current_time
  59. def verify_can_create_lists() -> None:
  60. curr_user = get_current_user_object()
  61. res = verify_groupadmin(False, curr_user) or curr_user.is_sisu_teacher
  62. if not res:
  63. raise AccessDenied("This action requires permission to create message lists")
  64. def verify_messagelist_name_requirements(name_candidate: str) -> None:
  65. """Checks name requirements specific for email list.
  66. If at any point a name requirement check fails, then an exception is raised an carried to the client. If all
  67. name requirements are met, then succeed silently.
  68. :param name_candidate: Name to check against naming rules.
  69. """
  70. # There might become a time when we also check here if name is some message list specific reserved name. We
  71. # haven't got a source of those reserved names, not including names that already exists, so no check at this time.
  72. verify_name_rules(name_candidate)
  73. verify_name_availability(name_candidate)
  74. def verify_name_availability(name_candidate: str) -> None:
  75. """Check if a message list with a given name already exists.
  76. :param name_candidate: The name to be checked if it already exists.
  77. """
  78. if MessageListModel.name_exists(name_candidate):
  79. raise RouteException(f"Message list with name {name_candidate} already exists.")
  80. # Regular expression patters used for name rule verification. They are kept here, so they are not re-compiled at
  81. # every name rule verification. The explanation of the rules is at their usage in verify_name_rules function.
  82. START_WITH_LOWERCASE_PATTER = re.compile(r"^[a-z]")
  83. SEQUENTIAL_DOTS_PATTERN = re.compile(r"\.\.+")
  84. # A Name cannot have allowed characters. This set of characters is an import from Korppi's character
  85. # limitations for email list names, and can probably be expanded in the future if desired.
  86. # lowercase letters a - z
  87. # digits 0 - 9
  88. # dot '.'
  89. # hyphen '-'
  90. # underscore '_'
  91. # The pattern is a negation of the actual rules.
  92. PROHIBITED_CHARACTERS_PATTERN = re.compile(r"[^a-z0-9.\-_]")
  93. REQUIRED_DIGIT_PATTERN = re.compile(r"\d")
  94. class NameRequirements(Enum):
  95. NAME_LENGTH_BOUNDED = 0
  96. START_WITH_LOWERCASE = 1
  97. NO_SEQUENTIAL_DOTS = 2
  98. NO_TRAILING_DOTS = 3
  99. NO_FORBIDDEN_CHARS = 4
  100. MIN_ONE_DIGIT = 5
  101. def check_name_rules(name_candidate: str) -> Iterator[NameRequirements]:
  102. """Check if name candidate complies with naming rules.
  103. :param name_candidate: What name we are checking against the rules.
  104. :return: A generator that returns violated name rules.
  105. """
  106. # Be careful when checking regex rules. Some rules allow a pattern to exist, while prohibiting others. Some
  107. # rules prohibit something, but allow other things to exist. If the explanation for a rule is different than
  108. # the regex, the explanation is more likely to be correct.
  109. # Name is within length boundaries.
  110. lower_bound = 5
  111. upper_bound = 36
  112. if not (lower_bound <= len(name_candidate) <= upper_bound):
  113. yield NameRequirements.NAME_LENGTH_BOUNDED
  114. # Name has to start with a lowercase letter.
  115. if not START_WITH_LOWERCASE_PATTER.search(name_candidate):
  116. yield NameRequirements.START_WITH_LOWERCASE
  117. # Name cannot have multiple dots in sequence.
  118. if SEQUENTIAL_DOTS_PATTERN.search(name_candidate):
  119. yield NameRequirements.NO_SEQUENTIAL_DOTS
  120. # Name cannot end in a dot.
  121. if name_candidate.endswith("."):
  122. yield NameRequirements.NO_TRAILING_DOTS
  123. if PROHIBITED_CHARACTERS_PATTERN.search(name_candidate):
  124. yield NameRequirements.NO_FORBIDDEN_CHARS
  125. # Name has to include at least one digit.
  126. if not REQUIRED_DIGIT_PATTERN.search(name_candidate):
  127. yield NameRequirements.MIN_ONE_DIGIT
  128. def verify_name_rules(name_candidate: str) -> None:
  129. """Check if name candidate complies with naming rules.
  130. The function raises a RouteException if naming rule is violated. If this function doesn't raise an exception,
  131. then the name candidate follows naming rules.
  132. :param name_candidate: What name we are checking against the rules.
  133. """
  134. res = next(check_name_rules(name_candidate), None)
  135. if res:
  136. raise RouteException(res.name)
  137. @dataclass
  138. class EmailAndDisplayName:
  139. """Wrapper for parsed email messages containing sender/receiver email and display name."""
  140. email: str
  141. name: str
  142. def to_json(self) -> dict[str, str]:
  143. res = {"email": self.email}
  144. if self.name:
  145. res["name"] = self.name
  146. return res
  147. @dataclass
  148. class BaseMessage:
  149. """A unified datastructure for messages TIM handles."""
  150. # Meta information about where this message belongs to and where its from. Mandatory values for all messages.
  151. message_list_name: str
  152. message_channel: Channel = field(
  153. metadata={"by_value": True}
  154. ) # Where the message came from.
  155. # Header information. Mandatory values for all messages.
  156. sender: EmailAndDisplayName
  157. recipients: list[EmailAndDisplayName]
  158. subject: str
  159. # Message body. Mandatory value for all messages.
  160. message_body: str
  161. # Email specific attributes.
  162. domain: str | None = None
  163. reply_to: EmailAndDisplayName | None = None
  164. # Timestamp for the message is a mandatory value. If the message comes from an outside source, it should already
  165. # have a time stamp. The default value is mostly for messages that would be generated inside TIM. It can also be
  166. # set for messages which for some reason don't already have any form of timestamp present.
  167. timestamp: datetime = get_current_time()
  168. # Path prefixes for documents and folders.
  169. MESSAGE_LIST_DOC_PREFIX = "messagelists"
  170. MESSAGE_LIST_ARCHIVE_FOLDER_PREFIX = "archives"
  171. def create_archive_doc_with_permission(
  172. archive_subject: str,
  173. archive_doc_path: str,
  174. message_list: MessageListModel,
  175. message: BaseMessage,
  176. ) -> DocEntry:
  177. """Create archive document with permissions matching the message list's archive policy.
  178. :param archive_subject: The subject of the archive document.
  179. :param archive_doc_path: The path where the archive document should be created.
  180. :param message_list: The message list where the message belongs.
  181. :param message: The message about to be archived.
  182. :return: The archive document.
  183. """
  184. # Gather owners of the archive document.
  185. message_owners: list[UserGroup] = []
  186. message_sender = User.get_by_email(message.sender.email)
  187. # List owners get a default ownership for the messages on a list. This covers the archive policy of SECRET.
  188. message_owners.extend(get_message_list_owners(message_list))
  189. # Sender will always be able to see their message
  190. if message_sender:
  191. message_owners.append(message_sender.get_personal_group())
  192. # Who gets to see a message in the archives.
  193. message_viewers: list[UserGroup] = []
  194. # Gather additional permissions to the archive doc.
  195. # The meanings of different archive settings are listed with ArchiveType class.
  196. if message_list.archive_policy is ArchiveType.PUBLIC:
  197. message_viewers.append(UserGroup.get_anonymous_group())
  198. elif message_list.archive_policy is ArchiveType.UNLISTED:
  199. message_viewers.append(UserGroup.get_logged_in_group())
  200. elif message_list.archive_policy is ArchiveType.GROUPONLY:
  201. message_viewers.extend([m.user_group for m in message_list.get_tim_members()])
  202. # Otherwise it's secret => no one but list owners and sender can see
  203. # List owner will always be at least one of the message owners
  204. archive_doc = DocEntry.create(
  205. title=archive_subject, path=archive_doc_path, owner_group=message_owners[0]
  206. )
  207. # Add the rest of the message owners.
  208. if len(message_owners) > 1:
  209. archive_doc.block.add_rights(message_owners[1:], AccessType.owner)
  210. # Add view rights.
  211. archive_doc.block.add_rights(message_viewers, AccessType.view)
  212. return archive_doc
  213. # Based on https://mathiasbynens.be/demo/url-regex with minor edits
  214. # This is one of the simplest patterns and it matches all cases correctly except for some special cases
  215. url_pattern = r"(https?|ftp)://[^\s/$.?#].[^\s]*"
  216. md_url_pattern = re.compile(
  217. rf"(\[([^]]*)\]\(({url_pattern})\))|({url_pattern})", re.IGNORECASE
  218. )
  219. def message_body_to_md(body: str) -> str:
  220. """
  221. Converts mail body into markdown.
  222. Importantly, the function
  223. * adds extra spacing for quotes
  224. * adds explicit newline to non-paragraph breaks
  225. * makes links clickable
  226. * cleans up Outlook safelinks
  227. :param body: Original message body.
  228. :return: Markdown-converted message body.
  229. """
  230. result: list[str] = []
  231. body_lines = body.splitlines(False)
  232. code_block = None
  233. quote_level = 0
  234. def fix_url(url: SplitResult) -> str:
  235. real_url = None
  236. # Outlook safelink
  237. if "safelinks.protection.outlook.com" in url.netloc:
  238. qs = parse_qs(url.query)
  239. real_url = qs.get("url", [""])[0]
  240. real_url = real_url or url.geturl()
  241. extra = ""
  242. # Usually URLs don't end with a dot, so it's reasonable to move it outside the link
  243. if real_url.endswith("."):
  244. real_url = real_url[:-1]
  245. extra = "."
  246. return f"<{real_url}>{extra}" if not code_block else real_url
  247. def handle_md_url(m: Match) -> str:
  248. md_url, raw_url = m.group(1), m.group(5)
  249. if raw_url:
  250. return fix_url(urlsplit(raw_url))
  251. else:
  252. return f"[{m.group(2)}]({fix_url(urlsplit(m.group(3)))})"
  253. def append_line(line_str: str = "") -> None:
  254. result.append((quote_level * ">") + line_str)
  255. def count_prefix_char(s: str, prefix_char: str) -> int:
  256. res = 0
  257. for c in s:
  258. if c.isspace():
  259. continue
  260. if c == prefix_char:
  261. res += 1
  262. else:
  263. break
  264. return res
  265. def strip_quotes(s: str) -> str:
  266. for _ in range(quote_level):
  267. index = next((ci for ci, c in enumerate(s) if c == ">"), None)
  268. if index is not None:
  269. s = s[index + 1 :]
  270. return s
  271. def is_list_start(s: str) -> bool:
  272. return s.startswith("-") or s.startswith("*")
  273. for i, line in enumerate(body_lines):
  274. # plaintext boundary if it's present, simply ignore since we'd rather save just the plaintext mail
  275. if line == "--- mail_boundary ---":
  276. break
  277. line = md_url_pattern.sub(handle_md_url, line)
  278. prev = body_lines[i - 1] if i > 0 else ""
  279. cur_quote_level = count_prefix_char(line, ">")
  280. prev_quote_level = count_prefix_char(prev, ">")
  281. # Strip quotes after computing line's quote level
  282. line = strip_quotes(line)
  283. cur = line.strip()
  284. prev = prev.strip()
  285. # Headers are not common in emails, so it's better to just paste them verbatim
  286. if cur.startswith("#"):
  287. line = line.replace("#", "\\#")
  288. # Code block start/end
  289. if cur.startswith("```"):
  290. if not code_block:
  291. code_block_end = count_prefix_char(cur, "`")
  292. code_block = cur[:code_block_end]
  293. elif cur.startswith(code_block):
  294. code_block = None
  295. append_line(line)
  296. continue
  297. # Code block -> handle verbatim
  298. if code_block:
  299. append_line(line)
  300. continue
  301. # Quote level mismatch => quote level is changed, append newline and change quote level
  302. if cur_quote_level != prev_quote_level:
  303. # If we go deeper, add newline on current level
  304. if cur_quote_level > prev_quote_level and prev:
  305. append_line()
  306. quote_level = cur_quote_level
  307. # If we return from quote, add newline on new level
  308. if cur_quote_level < prev_quote_level and cur:
  309. append_line()
  310. # Reset line and current values since we changed quote level
  311. line = strip_quotes(line)
  312. cur = line.strip()
  313. prev = ""
  314. cur_is_list_start = is_list_start(cur)
  315. # If the current line starts a list and prev line is not empty,
  316. # add a newline => forces a new paragraph for a list in markdown
  317. if cur_is_list_start and prev:
  318. append_line()
  319. # Previous and current lines are non-empty lists => force newline on previous line
  320. if not cur_is_list_start and prev and cur:
  321. result[-1] += " "
  322. append_line(line)
  323. # Close the opened code block
  324. if code_block:
  325. append_line(code_block)
  326. return "\n".join(result)
  327. def check_archives_folder_exists(message_list: MessageListModel) -> Folder | None:
  328. """
  329. Ensures archive folder exists for the given list if the list is archived.
  330. :param message_list: Message list to check
  331. :return: The archive folder for the list if the list should be archived. Otherwise None.
  332. """
  333. if message_list.archive_policy is ArchiveType.NONE:
  334. return None
  335. archive_folder_path = f"{MESSAGE_LIST_ARCHIVE_FOLDER_PREFIX}/{remove_path_special_chars(message_list.name)}"
  336. archive_folder = Folder.find_by_path(archive_folder_path)
  337. if archive_folder is None:
  338. owners = get_message_list_owners(message_list)
  339. archive_folder = Folder.create(
  340. archive_folder_path, owner_groups=owners, title=f"{message_list.name}"
  341. )
  342. return archive_folder
  343. def archive_message(message_list: MessageListModel, message: BaseMessage) -> None:
  344. """Archive a message for a message list.
  345. :param message_list: The message list where the archived message belongs.
  346. :param message: The message being archived.
  347. """
  348. archive_folder = check_archives_folder_exists(message_list)
  349. # Don't archive if there is nothing to archive to (e.g. list's archives are disabled)
  350. if not archive_folder:
  351. return
  352. # Don't save spam
  353. if message.subject.lower().startswith("[spam]"):
  354. return
  355. message_doc_subject = message.subject
  356. if message_list.subject_prefix:
  357. message_doc_subject = message_doc_subject.removeprefix(
  358. message_list.subject_prefix
  359. )
  360. message_doc_name = message_doc_subject.replace("/", "-")
  361. archive_doc_path = remove_path_special_chars(
  362. f"{archive_folder.path}/{message_doc_name}-"
  363. f"{get_current_time().strftime('%Y-%m-%d %H:%M:%S')}"
  364. )
  365. archive_doc = create_archive_doc_with_permission(
  366. message.subject, archive_doc_path, message_list, message
  367. )
  368. archive_doc.document.add_setting(
  369. "macros",
  370. {
  371. "message": {
  372. "sender": message.sender.to_json(),
  373. "recipients": [r.to_json() for r in message.recipients],
  374. "date": message.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
  375. }
  376. },
  377. )
  378. # Set header information for archived message.
  379. archive_doc.document.add_paragraph(
  380. "<tim-archive-header message='%%message|tojson%%'></tim-archive-header>",
  381. attrs={"allowangular": "true"},
  382. )
  383. # Set message body for archived message.
  384. # TODO: Check message list's only_text flag.
  385. archive_doc.document.add_paragraph(
  386. message_body_to_md(message.message_body), attrs={"taskId": "message-body"}
  387. )
  388. archive_doc.document.add_paragraph(
  389. "<tim-archive-footer message='%%message|tojson%%'></tim-archive-footer>",
  390. attrs={"allowangular": "true"},
  391. )
  392. db.session.commit()
  393. def parse_mailman_message(original: dict, msg_list: MessageListModel) -> BaseMessage:
  394. """Modify an email message sent from Mailman to TIM's universal message format.
  395. :param original: An email message sent from Mailman.
  396. :param msg_list: The message list where original is meant to go.
  397. :return: A BaseMessage object corresponding the original email message.
  398. """
  399. # original message is of form specified in https://pypi.org/project/mail-parser/
  400. visible_recipients: list[EmailAndDisplayName] = []
  401. maybe_to_addresses = parse_mailman_message_address(original, "to")
  402. if maybe_to_addresses is not None:
  403. visible_recipients.extend(maybe_to_addresses)
  404. maybe_cc_addresses = parse_mailman_message_address(original, "cc")
  405. if maybe_cc_addresses is not None:
  406. visible_recipients.extend(maybe_cc_addresses)
  407. sender: EmailAndDisplayName | None = None
  408. maybe_from_address = parse_mailman_message_address(original, "from")
  409. if maybe_from_address is not None:
  410. # Expect only one sender.
  411. sender = maybe_from_address[0]
  412. if sender is None:
  413. # If no sender is found on a message, we don't archive the message.
  414. raise RouteException("No sender found in the message.")
  415. message_subject = original.get("subject", "No subject")
  416. message_body = original.get("body", "")
  417. message = BaseMessage(
  418. message_list_name=msg_list.name,
  419. domain=msg_list.email_list_domain,
  420. message_channel=Channel.EMAIL_LIST,
  421. # Header information
  422. sender=sender,
  423. recipients=visible_recipients,
  424. subject=message_subject,
  425. # Message body
  426. message_body=message_body,
  427. )
  428. # Try parsing the rest of email specific fields.
  429. if "reply_to" in original:
  430. message.reply_to = original["reply_to"]
  431. if "date" in original:
  432. try:
  433. # At first we except RFC5322 format Date header.
  434. message.timestamp = parsedate_to_datetime(original["date"])
  435. except (TypeError, ValueError):
  436. # Being here means that the date field is not in RFC5322 format. Testing has shown that ISO8601 format is
  437. # then a likely candidate format for Date header. Try parsing that format.
  438. try:
  439. message.timestamp = datetime.fromisoformat(original["date"])
  440. except ValueError:
  441. # Being here means that the date field was none of tried formats after all. We'll log the format the
  442. # date was in so that it can be fixed.
  443. log_warning(
  444. f"Function parse_mailman_message has encountered a Date header format it cannot handle. The "
  445. f"date is of format {original['date']}. Please handle this at earliest convenience."
  446. )
  447. return message
  448. def parse_mailman_message_address(
  449. original: dict, header: str
  450. ) -> list[EmailAndDisplayName] | None:
  451. """Parse (potentially existing) fields 'from' 'to', 'cc', or 'bcc' from a dict representing Mailman's email message.
  452. The fields are in lists, with individual list indicies being lists themselves of the form
  453. ['Display Name', 'email@domain.fi']
  454. :param original: Original message.
  455. :param header: One of "from", "to", "cc" or "bcc".
  456. :return: Return None if the header is not one of "from", "to", "cc" or "bcc". Otherwise return a list of
  457. EmailAndDisplayName objects.
  458. """
  459. if header not in ["from", "to", "cc", "bcc"]:
  460. return None
  461. email_name_pairs: list[EmailAndDisplayName] = []
  462. if header in original:
  463. for email_name_pair in original[header]:
  464. new_email_name_pair = EmailAndDisplayName(
  465. email=email_name_pair[1], name=email_name_pair[0]
  466. )
  467. email_name_pairs.append(new_email_name_pair)
  468. return email_name_pairs
  469. def get_message_list_owners(mlist: MessageListModel) -> list[UserGroup]:
  470. """Get the owners of a message list.
  471. :param mlist: The message list we want to know the owners.
  472. :return: A list of owners, as their personal user group.
  473. """
  474. manage_doc_block = Block.query.filter_by(id=mlist.manage_doc_id).one()
  475. return manage_doc_block.owners
  476. def create_management_doc(
  477. msg_list_model: MessageListModel, list_options: ListInfo
  478. ) -> DocInfo:
  479. """Create management doc for a new message list.
  480. :param msg_list_model: The message list the management document is created for.
  481. :param list_options: Options for creating the management document.
  482. :return: Newly created management document.
  483. """
  484. doc = create_document(
  485. f"/{MESSAGE_LIST_DOC_PREFIX}/{remove_path_special_chars(list_options.name)}",
  486. list_options.name,
  487. validation_rule=ItemValidationRule(check_write_perm=False),
  488. parent_owner=UserGroup.get_admin_group(),
  489. )
  490. apply_template(doc)
  491. s = doc.document.get_settings().get_dict().get("macros", {})
  492. s["messagelist"] = list_options.name
  493. doc.document.add_setting("macros", s)
  494. # Set the management doc for the message list.
  495. msg_list_model.manage_doc_id = doc.id
  496. return doc
  497. def new_list(list_options: ListInfo) -> tuple[DocInfo, MessageListModel]:
  498. """Adds a new message list into the database and creates the list's management doc.
  499. :param list_options: The list information for creating a new message list. Used to carry list's name and archive
  500. policy.
  501. :return: The management document of the message list.
  502. :return: The message list db model.
  503. """
  504. msg_list = MessageListModel(name=list_options.name, archive=list_options.archive)
  505. db.session.add(msg_list)
  506. doc_info = create_management_doc(msg_list, list_options)
  507. check_archives_folder_exists(msg_list)
  508. return doc_info, msg_list
  509. def set_message_list_notify_owner_on_change(
  510. message_list: MessageListModel, notify_owners_on_list_change_flag: bool | None
  511. ) -> None:
  512. """Set the notify list owner on list change flag for a list, and update necessary channels with this information.
  513. If the message list has an email list as a message channel, this will set the equilavent flag on the email list.
  514. :param message_list: The message list where the flag is being set.
  515. :param notify_owners_on_list_change_flag: An optional boolean flag. If True, then changes on the message list sends
  516. notifications to list owners. If False, notifications won't be sent. If None, nothing is set.
  517. """
  518. if (
  519. notify_owners_on_list_change_flag is None
  520. or message_list.notify_owner_on_change == notify_owners_on_list_change_flag
  521. ):
  522. return
  523. message_list.notify_owner_on_change = notify_owners_on_list_change_flag
  524. if message_list.email_list_domain:
  525. # Email lists have their own flag for notifying list owners for list changes.
  526. email_list = get_email_list_by_name(
  527. message_list.name, message_list.email_list_domain
  528. )
  529. set_notify_owner_on_list_change(email_list, message_list.notify_owner_on_change)
  530. def set_message_list_member_can_unsubscribe(
  531. message_list: MessageListModel, can_unsubscribe_flag: bool | None
  532. ) -> None:
  533. """Set the list member's free unsubscription flag, and propagate that setting to channels that have own handling
  534. of unsubscription.
  535. If the message list has an email list as a message channel, this will set the equilavent flag on the email list.
  536. :param message_list: Message list where the flag is being set.
  537. :param can_unsubscribe_flag: An optional boolean flag. For True, the member can unsubscribe on their own. For False,
  538. then the member can't unsubscribe from the list on their own. If None, then the current value is kept.
  539. """
  540. if (
  541. can_unsubscribe_flag is None
  542. or message_list.can_unsubscribe == can_unsubscribe_flag
  543. ):
  544. return
  545. message_list.can_unsubscribe = can_unsubscribe_flag
  546. if message_list.email_list_domain:
  547. # Email list's have their own settings for unsubscription.
  548. email_list = get_email_list_by_name(
  549. message_list.name, message_list.email_list_domain
  550. )
  551. set_email_list_unsubscription_policy(email_list, can_unsubscribe_flag)
  552. def set_message_list_subject_prefix(
  553. message_list: MessageListModel, subject_prefix: str | None
  554. ) -> None:
  555. """Set the message list's subject prefix.
  556. If the message list has an email list as a message list, then set the subject prefix there also.
  557. Sets one extra space automatically to offset prefix from the actual title.
  558. :param message_list: The message list where the subject prefix is being set.
  559. :param subject_prefix: The prefix set for messages that go through the list. If None, then the current value is
  560. kept.
  561. """
  562. if subject_prefix is None or message_list.subject_prefix == subject_prefix:
  563. return
  564. # Add an extra space, if there is none.
  565. if not subject_prefix.endswith(" "):
  566. subject_prefix = f"{subject_prefix} "
  567. message_list.subject_prefix = subject_prefix
  568. if message_list.email_list_domain:
  569. email_list = get_email_list_by_name(
  570. message_list.name, message_list.email_list_domain
  571. )
  572. set_email_list_subject_prefix(email_list, subject_prefix)
  573. def set_message_list_tim_users_can_join(
  574. message_list: MessageListModel, can_join_flag: bool | None
  575. ) -> None:
  576. """Set the flag controlling if TIM users can directly join this list.
  577. Because the behaviour that is controlled by the can_join_flag applies to TIM users, there is no message channel
  578. specific handling.
  579. :param message_list: Message list where the flag is being set.
  580. :param can_join_flag: An optional boolean flag. If True, then TIM users can directly join this list, no moderation
  581. needed. If False, then TIM users can't direclty join the message list. If None, the current value is kept.
  582. """
  583. if can_join_flag is None or message_list.tim_user_can_join == can_join_flag:
  584. return
  585. message_list.tim_user_can_join = can_join_flag
  586. def set_message_list_default_send_right(
  587. message_list: MessageListModel, default_send_right_flag: bool | None
  588. ) -> None:
  589. """Set the default message list new member send right flag.
  590. :param message_list: The message list where the flag is set.
  591. :param default_send_right_flag: An optional boolean flag. For True, new members on the list get default send right.
  592. For False, new members don't get a send right. For None, the current value is kept.
  593. """
  594. if (
  595. default_send_right_flag is None
  596. or message_list.default_send_right == default_send_right_flag
  597. ):
  598. return
  599. message_list.default_send_right = default_send_right_flag
  600. def set_message_list_default_delivery_right(
  601. message_list: MessageListModel, default_delivery_right_flag: bool | None
  602. ) -> None:
  603. """Set the message list new member default delivery right.
  604. :param message_list: The message list where the flag is set.
  605. :param default_delivery_right_flag: An optional boolean flag. For True, new members on the list get default delivery
  606. right. For False, new members don't automatically get a delivery right. For None, the current value is kept.
  607. """
  608. if (
  609. default_delivery_right_flag is None
  610. or message_list.default_delivery_right == default_delivery_right_flag
  611. ):
  612. return
  613. message_list.default_delivery_right = default_delivery_right_flag
  614. def set_message_list_only_text(
  615. message_list: MessageListModel, only_text: bool | None
  616. ) -> None:
  617. """Set the flag controlling if message list is to accept text-only messages.
  618. :param message_list: The message list where the flag is to be set.
  619. :param only_text: An optional boolean flag. For True, the message list is set to text-only mode. For False, the
  620. message list accepts HTML-based messages. For None, the current value is kept.
  621. """
  622. if only_text is None or message_list.only_text == only_text:
  623. return
  624. message_list.only_text = only_text
  625. if message_list.email_list_domain:
  626. email_list = get_email_list_by_name(
  627. message_list.name, message_list.email_list_domain
  628. )
  629. set_email_list_only_text(email_list, only_text)
  630. def set_message_list_non_member_message_pass(
  631. message_list: MessageListModel, non_member_message_pass_flag: bool | None
  632. ) -> None:
  633. """Set message list's non member message pass flag.
  634. :param message_list: The message list where the flag is set.
  635. :param non_member_message_pass_flag: An optional boolean flag. For True, sources outside the list can send messages
  636. to this list. If False, messages form sources outside the list will be hold for moderation. For None, the current
  637. value is kept.
  638. """
  639. if (
  640. non_member_message_pass_flag is None
  641. or message_list.non_member_message_pass == non_member_message_pass_flag
  642. ):
  643. return
  644. message_list.non_member_message_pass = non_member_message_pass_flag
  645. if message_list.email_list_domain:
  646. email_list = get_email_list_by_name(
  647. message_list.name, message_list.email_list_domain
  648. )
  649. set_email_list_allow_nonmember(email_list, non_member_message_pass_flag)
  650. def set_message_list_allow_attachments(
  651. message_list: MessageListModel, allow_attachments_flag: bool | None
  652. ) -> None:
  653. """Set the flag controlling if a message list accepts messages with attachments.
  654. :param message_list: The message list where the flag is to be set.
  655. :param allow_attachments_flag: An optional boolean flag. For True, the list will allow a pre-determined set of
  656. attachments. For False, no attachments are allowed. For None, the current value is kept.
  657. """
  658. if (
  659. allow_attachments_flag is None
  660. or message_list.allow_attachments == allow_attachments_flag
  661. ):
  662. return
  663. message_list.allow_attachments = allow_attachments_flag
  664. if message_list.email_list_domain:
  665. email_list = get_email_list_by_name(
  666. message_list.name, message_list.email_list_domain
  667. )
  668. set_email_list_allow_attachments(email_list, allow_attachments_flag)
  669. def set_message_list_default_reply_type(
  670. message_list: MessageListModel, default_reply_type: ReplyToListChanges | None
  671. ) -> None:
  672. """Set a value controlling how replies to a message list are steered.
  673. The reply type is analogous to email lists' operation of "Reply-To munging". Reply-To munging is a process where
  674. messages sent to list may be subject to having their Reply-To header changed from what the sender of the message
  675. initially used. This is mainly used (and sometimes abused) to steer conversation from announce-only lists (which
  676. don't accept posts from anyone except few select individuals) to separate discussion lists.
  677. :param message_list: The message list where the value is to be set.
  678. :param default_reply_type: An optional enumeration. For value NOCHANGES the user is completely left the control
  679. how to respond to messages sent from the list. For value ADDLIST the replies will be primarily steered towards
  680. the message list. For None, the current value is kept.
  681. """
  682. if (
  683. default_reply_type is None
  684. or message_list.default_reply_type == default_reply_type
  685. ):
  686. return
  687. message_list.default_reply_type = default_reply_type
  688. if message_list.email_list_domain:
  689. email_list = get_email_list_by_name(
  690. message_list.name, message_list.email_list_domain
  691. )
  692. set_email_list_default_reply_type(email_list, default_reply_type)
  693. def add_new_message_list_group(
  694. msg_list: MessageListModel,
  695. ug: UserGroup,
  696. send_right: bool,
  697. delivery_right: bool,
  698. em_list: MailingList | None,
  699. ) -> None:
  700. """Add new (user) group to a message list.
  701. For groups, checks that the adder has at least manage rights to group's admin doc.
  702. Performs a duplicate check for memberships. A duplicate member will not be added again to the list. The process
  703. of re-activating a removed member of a list is different. For re-activating an already existing member,
  704. use set_message_list_member_removed_status function.
  705. This is a direct add, meaning member's membership_verified attribute is set in this function. Use other means to
  706. invite members.
  707. :param msg_list: The message list where the group will be added.
  708. :param ug: The user group being added to a message list.
  709. :param send_right: Send right for user groups members, that will be added to the message list individually.
  710. :param delivery_right: Delivery right for user groups members, that will be added to the message list individually.
  711. :param em_list: An optional email list. If given, then all the members of the user group will also be subscribed to
  712. the email list.
  713. """
  714. # Check right to a group. Right checking is not required for personal groups, only user groups.
  715. if not ug.is_personal_group and not has_manage_access(ug.admin_doc):
  716. return
  717. # Check for membership duplicates.
  718. member = msg_list.find_member(username=ug.name, email=None)
  719. if member and not member.membership_ended:
  720. return
  721. # Add the user group as a member to the message list.
  722. new_group_member = MessageListTimMember(
  723. message_list_id=msg_list.id,
  724. group_id=ug.id,
  725. delivery_right=delivery_right,
  726. send_right=send_right,
  727. membership_verified=get_current_time(),
  728. )
  729. db.session.add(new_group_member)
  730. # Add group's individual members to message channels.
  731. if em_list is not None:
  732. for user in ug.users:
  733. # TODO: Search for a set of emails and a primary email here when users' additional emails are implemented.
  734. user_email = (
  735. user.email
  736. ) # In the future, we can search for a set of emails and a primary email here.
  737. add_email(
  738. em_list,
  739. user_email,
  740. email_owner_pre_confirmation=True,
  741. real_name=user.real_name,
  742. send_right=send_right,
  743. delivery_right=delivery_right,
  744. )
  745. def add_message_list_external_email_member(
  746. msg_list: MessageListModel,
  747. external_email: str,
  748. send_right: bool,
  749. delivery_right: bool,
  750. em_list: MailingList,
  751. display_name: str | None,
  752. ) -> None:
  753. """Add external member to a message list. External members at this moment only support external members to email
  754. lists.
  755. :param msg_list: Message list where the member is to be added.
  756. :param external_email: The email address of an external member to be added to the message list.
  757. :param send_right: The send right to the list by the new member.
  758. :param delivery_right: The delivery right to the list by the new member.
  759. :param em_list: The email list where this external member will be also added, because at this time external members
  760. only make sense for an email list.
  761. :param display_name: Optional name associated with the external member.
  762. """
  763. # Check for duplicate members.
  764. if msg_list.find_member(username=None, email=external_email):
  765. return
  766. new_member = MessageListExternalMember(
  767. email_address=external_email,
  768. display_name=display_name,
  769. delivery_right=delivery_right,
  770. send_right=send_right,
  771. message_list_id=msg_list.id,
  772. )
  773. db.session.add(new_member)
  774. add_email(
  775. em_list,
  776. external_email,
  777. email_owner_pre_confirmation=True,
  778. real_name=display_name,
  779. send_right=send_right,
  780. delivery_right=delivery_right,
  781. )
  782. def sync_message_list_on_add(user: User, new_group: UserGroup) -> None:
  783. """On adding a user to a new group, sync the user to user group's message lists.
  784. :param user: The user that was added to the new_group.
  785. :param new_group: The new group that the user was added to.
  786. """
  787. # TODO: This might become a bottle neck, as adding to group is often done in a loop and every sync is a potential
  788. # call to different message channels (now just Mailman). In order to rid ourselves of that, we might need to
  789. # revamp the syncing. A solution might be a call to (Mailman's) server (sidelining mailmanclient-library) with a
  790. # batch of users we want to add with necessary information, and then let the server handle adding in a loop
  791. # locally.
  792. # Get all the message lists for the user group.
  793. for group_tim_member in new_group.messagelist_membership:
  794. group_message_list: MessageListModel = group_tim_member.message_list
  795. # Propagate the adding on message list's message channels.
  796. if group_message_list.email_list_domain:
  797. # TODO: Find user's contact info for emails and add them accordingly.
  798. email_list = get_email_list_by_name(
  799. group_message_list.name, group_message_list.email_list_domain
  800. )
  801. add_email(
  802. email_list,
  803. user.email,
  804. True,
  805. user.real_name,
  806. group_tim_member.member.send_right,
  807. group_tim_member.member.delivery_right,
  808. )
  809. def sync_message_list_on_expire(user: User, old_group: UserGroup) -> None:
  810. """On removing a user from a user group, remove the user from all the message lists that watch the group.
  811. :param user: The user who was removed from the user group.
  812. :param old_group: The group where the user was removed from.
  813. """
  814. # TODO: This might become a bottle neck, as removing from group is often done in a loop and every sync is a
  815. # potential call to different message channels (now just Mailman). In order to rid ourselves of that,
  816. # we might need to revamp the syncing. A solution might be a call to (Mailman's) server (sidelining
  817. # mailmanclient-library) with a batch of users we want to add with necessary information, and then let the
  818. # server handle removing in a loop locally.
  819. # Get all the message lists for the user group.
  820. for group_tim_member in old_group.messagelist_membership:
  821. group_message_list: MessageListModel = group_tim_member.message_list
  822. # Propagate the deletion on message list's message channels.
  823. if group_message_list.email_list_domain:
  824. # TODO: Find user's contact info for emails and remove them accordingly.
  825. email_list = get_email_list_by_name(
  826. group_message_list.name, group_message_list.email_list_domain
  827. )
  828. email_list_member = get_email_list_member(email_list, user.email)
  829. remove_email_list_membership(email_list_member)
  830. def set_message_list_member_removed_status(
  831. member: MessageListMember,
  832. removed: datetime | None,
  833. email_list: MailingList | None,
  834. ) -> None:
  835. """Set the message list member's membership removed status.
  836. :param member: The member who's membership status is being set.
  837. :param removed: Member's date of removal from the message list. If None, then the member is an active member on the
  838. list.
  839. :param email_list: An email list belonging to the message list. If None, the message list does not have an email
  840. list.
  841. """
  842. if (member.membership_ended is None and removed is None) or (
  843. member.membership_ended and removed
  844. ):
  845. return
  846. member.remove(removed)
  847. # Remove members from email list or return them there.
  848. if email_list:
  849. if member.is_group():
  850. ug = member.tim_member.user_group
  851. ug_members = ug.users
  852. for ug_member in ug_members:
  853. mlist_member = get_email_list_member(email_list, ug_member.email)
  854. if removed:
  855. remove_email_list_membership(mlist_member)
  856. else:
  857. # Re-set the member's send and delivery rights on the email list.
  858. set_email_list_member_send_status(mlist_member, member.send_right)
  859. set_email_list_member_delivery_status(
  860. mlist_member, member.delivery_right
  861. )
  862. elif member.is_personal_user():
  863. # Make changes to member's status on the email list.
  864. mlist_member = get_email_list_member(email_list, member.get_email())
  865. # If there is an email list and the member is removed, do a soft removal on the email list.
  866. if removed:
  867. remove_email_list_membership(mlist_member)
  868. else:
  869. # Re-set the member's send and delivery rights on the email list.
  870. set_email_list_member_send_status(mlist_member, member.send_right)
  871. set_email_list_member_delivery_status(
  872. mlist_member, member.delivery_right
  873. )
  874. def set_member_send_delivery(
  875. member: MessageListMember,
  876. send: bool,
  877. delivery: bool,
  878. email_list: MailingList | None = None,
  879. ) -> None:
  880. """Set message list member's send and delivery rights.
  881. :param member: Member who's rights are being set.
  882. :param send: Member's new send right.
  883. :param delivery: Member's new delivery right.
  884. :param email_list: If the message list has email list as one of its message channels, set the send and delivery
  885. rights there also.
  886. :return: None.
  887. """
  888. # Send right
  889. if member.send_right != send:
  890. member.send_right = send
  891. if email_list:
  892. if member.is_personal_user():
  893. mlist_member = get_email_list_member(email_list, member.get_email())
  894. set_email_list_member_send_status(mlist_member, send)
  895. elif member.is_group():
  896. # For group, set the delivery status for its members on the email list.
  897. ug = member.tim_member.user_group
  898. ug_members = ug.users # ug.current_memberships
  899. for ug_member in ug_members:
  900. # user = ug_member.personal_user
  901. email_list_member = get_email_list_member(
  902. email_list, ug_member.email
  903. )
  904. set_email_list_member_send_status(email_list_member, send)
  905. # Delivery right.
  906. if member.delivery_right != delivery:
  907. member.delivery_right = delivery
  908. if email_list:
  909. # If message list has an email list associated with it, set delivery rights there.
  910. if member.is_personal_user():
  911. mlist_member = get_email_list_member(email_list, member.get_email())
  912. set_email_list_member_delivery_status(mlist_member, delivery)
  913. elif member.is_group():
  914. # For group, set the delivery status for its members on the email list.
  915. ug = member.tim_member.user_group
  916. ug_members = ug.users # ug.current_memberships
  917. for ug_member in ug_members:
  918. # user = ug_member.personal_user
  919. email_list_member = get_email_list_member(
  920. email_list, ug_member.email
  921. )
  922. set_email_list_member_delivery_status(email_list_member, delivery)
  923. def set_message_list_description(
  924. message_list: MessageListModel, description: str | None
  925. ) -> None:
  926. """Set a (short) description to a message list and its associated message channels.
  927. :param message_list: The message list where the description is set.
  928. :param description: The new description. If None, keep the current value.
  929. """
  930. if description is None or message_list.description == description:
  931. return
  932. message_list.description = description
  933. if message_list.email_list_domain:
  934. email_list = get_email_list_by_name(
  935. message_list.name, message_list.email_list_domain
  936. )
  937. set_email_list_description(email_list, description)
  938. def set_message_list_info(message_list: MessageListModel, info: str | None) -> None:
  939. """Set a long description (called 'info' on Mailman) to a message list and its associated message channels.
  940. :param message_list: The message list where the (long) description is set.
  941. :param info: The new long description. If None, keep the current value.
  942. """
  943. if info is None or message_list.info == info:
  944. return
  945. message_list.info = info
  946. if message_list.email_list_domain:
  947. email_list = get_email_list_by_name(
  948. message_list.name, message_list.email_list_domain
  949. )
  950. set_email_list_info(email_list, info)
  951. @dataclass
  952. class UserGroupDiff:
  953. add_user_ids: list[int]
  954. remove_user_ids: list[int]
  955. def sync_usergroup_messagelist_members(
  956. diffs: dict[int, UserGroupDiff], permanent_delete: bool = False
  957. ) -> None:
  958. user_ids = set(
  959. itertools.chain.from_iterable(
  960. [*u.add_user_ids, *u.remove_user_ids] for u in diffs.values()
  961. )
  962. )
  963. if not user_ids:
  964. return
  965. user_query = User.query.filter(User.id.in_(user_ids)).options(
  966. load_only(User.id, User.email, User.real_name)
  967. )
  968. users = {user.id: user for user in user_query}
  969. try:
  970. for ug_id, diff in diffs.items():
  971. ug_memberships = MessageListTimMember.query.filter_by(group_id=ug_id).all()
  972. for group_tim_member in ug_memberships:
  973. group_message_list: MessageListModel = group_tim_member.message_list
  974. if group_message_list.email_list_domain:
  975. email_list = get_email_list_by_name(
  976. group_message_list.name, group_message_list.email_list_domain
  977. )
  978. for add_id in diff.add_user_ids:
  979. user = users[add_id]
  980. add_email(
  981. email_list,
  982. user.email,
  983. True,
  984. user.real_name,
  985. group_tim_member.member.send_right,
  986. group_tim_member.member.delivery_right,
  987. )
  988. for remove_id in diff.remove_user_ids:
  989. user = users[remove_id]
  990. remove_email_list_membership(
  991. get_email_list_member(email_list, user.email),
  992. permanent_delete,
  993. )
  994. except HTTPError as e:
  995. log_mailman(e, "Failed to sync usergroups")