mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
Various fixes
This commit is contained in:
+1
-1
@@ -209,7 +209,7 @@ Required changes to :file:`settings.py`
|
||||
|
||||
- Remove ``NEW_ADMIN_URL`` variable
|
||||
|
||||
- Remove ``new_admin_url`` context processor
|
||||
- Remove ``new_admin_url`` and ``top_notifications`` context processors
|
||||
|
||||
- Remove ``ckeditor`` related apps from ``INSTALLED_APPS`` variable
|
||||
|
||||
|
||||
@@ -94,8 +94,8 @@ export default {
|
||||
}
|
||||
return repository.post('/webmail/emails/flag/', body)
|
||||
},
|
||||
sendEmail(body) {
|
||||
return repository.post('/webmail/emails/send/', body)
|
||||
sendEmailFromComposeSession(uid, body) {
|
||||
return repository.post(`/webmail/compose-sessions/${uid}/send/`, body)
|
||||
},
|
||||
getComposeSession(uid) {
|
||||
return repository.get(`/webmail/compose-sessions/${uid}/`)
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
<v-spacer></v-spacer>
|
||||
</v-toolbar>
|
||||
</template>
|
||||
<template #[`item.display_name`]="{ item }">
|
||||
<template v-if="item.display_name">
|
||||
{{ item.display_name }}
|
||||
</template>
|
||||
<template v-else> {{ item.fist_name }} {{ item.last_name }} </template>
|
||||
</template>
|
||||
<template #[`item.emails`]="{ item }">
|
||||
{{ displayEmails(item.emails) }}
|
||||
</template>
|
||||
|
||||
@@ -90,7 +90,7 @@ const removeAttachment = async (attachment) => {
|
||||
attachments.value.splice(index, 1)
|
||||
}
|
||||
|
||||
api.getUploadedAttachments(props.sessionUid).then((resp) => {
|
||||
attachments.value = resp.data
|
||||
api.getComposeSession(props.sessionUid).then((resp) => {
|
||||
attachments.value = resp.data.attachments
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -263,7 +263,7 @@ const submit = async () => {
|
||||
body.bcc = bcc
|
||||
}
|
||||
try {
|
||||
await api.sendEmail(body)
|
||||
await api.sendEmailFromComposeSession(route.query.uid, body)
|
||||
router.push({ name: 'MailboxView' })
|
||||
displayNotification({ msg: $gettext('Email sent') })
|
||||
} catch (error) {
|
||||
|
||||
@@ -134,7 +134,7 @@ onMounted(() => {
|
||||
capabilitiesApi.getCapabilities().then((response) => {
|
||||
isRspamdVisible.value = 'rspamd' in response.data.capabilities
|
||||
if (isRspamdVisible.value) {
|
||||
rspamd_url.value =
|
||||
rspamdUrl.value =
|
||||
response.data.capabilities.rspamd.location || '/rspamd'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ export default defineConfig({
|
||||
}),
|
||||
],
|
||||
base: '/',
|
||||
define: { 'process.env': {}, __VUE_OPTIONS_API__: false },
|
||||
define: { 'process.env': {} },
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Admin serializers."""
|
||||
|
||||
from typing import List
|
||||
|
||||
from django.contrib.auth import password_validation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -277,7 +275,7 @@ class AccountSerializer(serializers.ModelSerializer):
|
||||
"""Return role."""
|
||||
return account.role
|
||||
|
||||
def get_domains(self, account) -> List[str]:
|
||||
def get_domains(self, account) -> list[str]:
|
||||
"""Return domains administered by this account."""
|
||||
if account.role not in ["DomainAdmins", "Resellers"]:
|
||||
return []
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -388,7 +387,7 @@ class AccountMeSerializer(v1_serializers.AccountSerializer):
|
||||
class Meta(v1_serializers.AccountSerializer.Meta):
|
||||
fields = v1_serializers.AccountSerializer.Meta.fields + ("permissions",)
|
||||
|
||||
def get_permissions(self, user) -> List[str]:
|
||||
def get_permissions(self, user) -> list[str]:
|
||||
"""Return available permissions for given user."""
|
||||
permissions = (
|
||||
user.user_permissions.all() | Permission.objects.filter(group__user=user)
|
||||
@@ -412,7 +411,7 @@ class AccountSerializer(v1_serializers.AccountSerializer):
|
||||
return
|
||||
self.fields["resources"] = serializers.SerializerMethodField()
|
||||
|
||||
def get_resources(self, account) -> List[AccountResourceSerializer]:
|
||||
def get_resources(self, account) -> list[AccountResourceSerializer]:
|
||||
if account.role == "SimpleUsers":
|
||||
return []
|
||||
resources = []
|
||||
|
||||
@@ -48,7 +48,7 @@ class AdminObject(models.Model):
|
||||
def from_db(cls, db, field_names, values):
|
||||
"""Store loaded values."""
|
||||
instance = super().from_db(db, field_names, values)
|
||||
instance._loaded_values = dict(zip(field_names, values))
|
||||
instance._loaded_values = dict(zip(field_names, values, strict=False))
|
||||
return instance
|
||||
|
||||
@property
|
||||
|
||||
@@ -56,7 +56,7 @@ class ARmessage(models.Model):
|
||||
)
|
||||
content = self.content % context
|
||||
name = "autoreply"
|
||||
action = [("vacation", ":subject", self.subject, ":days", days, content)]
|
||||
action = [("vacation", ":subject", self.subject, ":days", str(days), content)]
|
||||
if not fset.getfilter(name):
|
||||
fset.addfilter(name, condition, action)
|
||||
else:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import csv
|
||||
from typing import Optional
|
||||
|
||||
from modoboa.contacts.importer.backends.outlook import OutlookBackend
|
||||
|
||||
@@ -29,7 +28,7 @@ def import_csv_file(
|
||||
backend_name: str,
|
||||
csv_filename: str,
|
||||
delimiter: str,
|
||||
carddav_password: Optional[str] = None,
|
||||
carddav_password: str | None = None,
|
||||
):
|
||||
with open(csv_filename) as fp:
|
||||
backend, rows = get_import_backend(fp, delimiter, backend_name)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Extension management."""
|
||||
|
||||
from typing import Dict, Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import include
|
||||
from django.urls import re_path
|
||||
@@ -22,8 +20,8 @@ class ModoExtension:
|
||||
description: str = ""
|
||||
needs_media: bool = False
|
||||
always_active: bool = False
|
||||
url: Optional[str] = None
|
||||
topredirection_url: Optional[str] = None
|
||||
url: str | None = None
|
||||
topredirection_url: str | None = None
|
||||
|
||||
def get_available_apps(self) -> list:
|
||||
return []
|
||||
@@ -59,7 +57,7 @@ class ExtensionsPool:
|
||||
"""The extensions manager"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.extensions: Dict[str, Dict] = {}
|
||||
self.extensions: dict[str, dict] = {}
|
||||
|
||||
def register_extension(self, ext: ModoExtension, show: bool = True) -> None:
|
||||
"""Register an extension.
|
||||
@@ -69,7 +67,7 @@ class ExtensionsPool:
|
||||
"""
|
||||
self.extensions[ext.name] = {"cls": ext, "show": show}
|
||||
|
||||
def get_extension(self, name: str) -> Optional[ModoExtension]:
|
||||
def get_extension(self, name: str) -> ModoExtension | None:
|
||||
"""Retrieve the current instance of an extension."""
|
||||
if name not in self.extensions:
|
||||
return None
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import re
|
||||
from email.header import Header
|
||||
from typing import Optional
|
||||
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.conf import settings
|
||||
@@ -132,7 +131,7 @@ class User(AbstractUser):
|
||||
raw_value = smart_bytes(raw_value)
|
||||
return get_password_hasher(scheme.upper())().encrypt(raw_value)
|
||||
|
||||
def set_password(self, raw_value: str, curvalue: Optional[str] = None) -> None:
|
||||
def set_password(self, raw_value: str, curvalue: str | None = None) -> None:
|
||||
"""Password update.
|
||||
|
||||
Update the current mailbox's password with the given clear
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
"""A plugin to migrate mailboxes using IMAP."""
|
||||
|
||||
from pkg_resources import get_distribution, DistributionNotFound
|
||||
|
||||
|
||||
try:
|
||||
__version__ = get_distribution(__name__).version
|
||||
except DistributionNotFound:
|
||||
# package is not installed
|
||||
pass
|
||||
|
||||
default_app_config = "modoboa.imap_migration.apps.IMAPMigrationConfig"
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
|
||||
@@ -14,7 +12,7 @@ class ModoboaException(Exception):
|
||||
Base class for Modoboa custom exceptions.
|
||||
"""
|
||||
|
||||
http_code: Optional[int] = None
|
||||
http_code: int | None = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if "http_code" in kwargs:
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Test cases for the limits extension."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from modoboa.admin.factories import populate_database
|
||||
@@ -76,7 +74,7 @@ class ResourceTestCase(lib_tests.ModoAPITestCase):
|
||||
self.assertEqual(response.status_code, status)
|
||||
return response
|
||||
|
||||
def _create_alias(self, email, rcpt: Optional[list] = None, status=201):
|
||||
def _create_alias(self, email, rcpt: list | None = None, status=201):
|
||||
if rcpt is None:
|
||||
rcpt = ["user@test.com"]
|
||||
values = {"address": email, "recipients": rcpt, "enabled": True}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Parameters management."""
|
||||
|
||||
import copy
|
||||
from typing import Optional
|
||||
|
||||
from rest_framework.fields import empty
|
||||
|
||||
@@ -102,7 +101,7 @@ class Registry:
|
||||
raise NotDefined(app)
|
||||
return self._registry2[level][app]["serializer_class"]
|
||||
|
||||
def get_structure(self, level: str, for_app: Optional[str] = None) -> list:
|
||||
def get_structure(self, level: str, for_app: str | None = None) -> list:
|
||||
"""Return fields structure."""
|
||||
result = []
|
||||
for app, fields in list(self._registry2[level].items()):
|
||||
@@ -141,7 +140,7 @@ class Registry:
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
def exists(self, level: str, app: str, parameter: Optional[str] = None) -> bool:
|
||||
def exists(self, level: str, app: str, parameter: str | None = None) -> bool:
|
||||
"""Check if parameter exists."""
|
||||
parameters = self._registry2[level]
|
||||
result = app in parameters
|
||||
@@ -193,7 +192,7 @@ class Manager:
|
||||
return self._parameters[app].get(parameter, default)
|
||||
return default
|
||||
|
||||
def get_values(self, app: Optional[str] = None):
|
||||
def get_values(self, app: str | None = None):
|
||||
"""Return all values for the given level/app."""
|
||||
if app is None:
|
||||
app = guess_extension_name()
|
||||
@@ -225,7 +224,7 @@ class Manager:
|
||||
)
|
||||
self._parameters[app][parameter] = value
|
||||
|
||||
def set_values(self, values: dict, app: Optional[str] = None) -> None:
|
||||
def set_values(self, values: dict, app: str | None = None) -> None:
|
||||
"""Set/update values for the given app."""
|
||||
if app is None:
|
||||
app = guess_extension_name()
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
"""The sieve filters editor of Modoboa."""
|
||||
|
||||
from pkg_resources import get_distribution, DistributionNotFound
|
||||
|
||||
|
||||
try:
|
||||
__version__ = get_distribution(__name__).version
|
||||
except DistributionNotFound:
|
||||
# package is not installed
|
||||
pass
|
||||
|
||||
default_app_config = "modoboa_sievefilters.apps.SieveFiltersConfig"
|
||||
default_app_config = "modoboa.sievefilters.apps.SieveFiltersConfig"
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Sievefilters serializers."""
|
||||
|
||||
from typing import List, Tuple
|
||||
|
||||
from sievelib import commands
|
||||
from sievelib.factory import Filter
|
||||
|
||||
@@ -61,7 +59,7 @@ class FilterSerializer(serializers.Serializer):
|
||||
actions = ActionSerializer(many=True)
|
||||
|
||||
@staticmethod
|
||||
def from_filters(filters: List[Filter]) -> "FilterSerializer":
|
||||
def from_filters(filters: list[Filter]) -> "FilterSerializer":
|
||||
result = []
|
||||
for fobj in filters:
|
||||
if fobj["name"] == "autoreply":
|
||||
@@ -117,9 +115,9 @@ class FilterSerializer(serializers.Serializer):
|
||||
result.append(item)
|
||||
return FilterSerializer(result, many=True)
|
||||
|
||||
def to_filter(self) -> Tuple[str, List, List]:
|
||||
def to_filter(self) -> tuple[str, list, list]:
|
||||
"""Convert serializer data to filter representation."""
|
||||
conditions: List[Tuple] = []
|
||||
conditions: list[tuple] = []
|
||||
actions = []
|
||||
match_type = self.validated_data["match_type"]
|
||||
if match_type == "all":
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Value loading functions."""
|
||||
|
||||
from typing import List, Optional, TypedDict
|
||||
from typing import TypedDict
|
||||
|
||||
from modoboa.webmail.lib import imaputils
|
||||
|
||||
@@ -13,9 +13,9 @@ class UserMailbox(TypedDict):
|
||||
|
||||
|
||||
def __build_folders_list(
|
||||
folders: list, user, imapc: imaputils.IMAPconnector, parentmb: Optional[str] = None
|
||||
) -> List[UserMailbox]:
|
||||
ret: List[UserMailbox] = []
|
||||
folders: list, user, imapc: imaputils.IMAPconnector, parentmb: str | None = None
|
||||
) -> list[UserMailbox]:
|
||||
ret: list[UserMailbox] = []
|
||||
for fd in folders:
|
||||
value = fd["path"] if "path" in fd else fd["name"]
|
||||
if parentmb:
|
||||
@@ -33,7 +33,7 @@ def __build_folders_list(
|
||||
return ret
|
||||
|
||||
|
||||
def user_mailboxes(request) -> List[UserMailbox]:
|
||||
def user_mailboxes(request) -> list[UserMailbox]:
|
||||
"""Retrieve list of available mailboxes for given user."""
|
||||
with imaputils.get_imapconnector(request, with_namespaces=False) as mbc:
|
||||
ret = mbc.getmboxes(request.user)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Internal tools."""
|
||||
|
||||
from typing import Optional, Tuple, Union
|
||||
|
||||
from sievelib.factory import FiltersSet
|
||||
from sievelib import managesieve
|
||||
from sievelib.parser import Parser
|
||||
@@ -27,7 +25,7 @@ class SieveClient:
|
||||
|
||||
msc: managesieve.Client
|
||||
|
||||
def __init__(self, user: Optional[str] = None, password: Optional[str] = None):
|
||||
def __init__(self, user: str | None = None, password: str | None = None):
|
||||
if user and password:
|
||||
try:
|
||||
ret, msg = self.login(user, password)
|
||||
@@ -36,9 +34,9 @@ class SieveClient:
|
||||
if not ret:
|
||||
raise SieveClientError(msg)
|
||||
|
||||
def login(self, user: str, password: str) -> Tuple[bool, Union[str, None]]:
|
||||
def login(self, user: str, password: str) -> tuple[bool, str | None]:
|
||||
conf = dict(param_tools.get_global_parameters("sievefilters"))
|
||||
self.msc = managesieve.Client(conf["server"], conf["port"], debug=True)
|
||||
self.msc = managesieve.Client(conf["server"], conf["port"], debug=False)
|
||||
try:
|
||||
ret = self.msc.connect(
|
||||
user, password, starttls=conf["starttls"], authmech="OAUTHBEARER"
|
||||
@@ -59,7 +57,7 @@ class SieveClient:
|
||||
def listscripts(self):
|
||||
return self.msc.listscripts()
|
||||
|
||||
def getscript(self, name: str, format: str = "raw") -> Union[FiltersSet, str, None]:
|
||||
def getscript(self, name: str, format: str = "raw") -> FiltersSet | str | None:
|
||||
content = self.msc.getscript(name)
|
||||
if content is None:
|
||||
raise SieveClientError(self.msc.errmsg.decode())
|
||||
|
||||
@@ -3,7 +3,6 @@ from email.mime.base import MIMEBase
|
||||
import json
|
||||
import os
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
import six
|
||||
@@ -81,7 +80,7 @@ def save_attachment(request, session_uid: str, f) -> dict:
|
||||
fp = NamedTemporaryFile(dir=get_storage_path(""), delete=False)
|
||||
except Exception as e:
|
||||
raise InternalError(str(e)) from None
|
||||
if isinstance(f, (six.binary_type, six.text_type)):
|
||||
if isinstance(f, six.binary_type | six.text_type):
|
||||
fp.write(smart_bytes(f))
|
||||
else:
|
||||
for chunk in f.chunks():
|
||||
@@ -100,7 +99,7 @@ def save_attachment(request, session_uid: str, f) -> dict:
|
||||
return attachment
|
||||
|
||||
|
||||
def remove_attachment(request, session_uid: str, name: str) -> Optional[str]:
|
||||
def remove_attachment(request, session_uid: str, name: str) -> str | None:
|
||||
manager = ComposeSessionManager(request.user.username)
|
||||
session = manager.get_content(session_uid)
|
||||
for att in session["attachments"]:
|
||||
|
||||
@@ -5,7 +5,6 @@ import imaplib
|
||||
import re
|
||||
import ssl
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -108,7 +107,7 @@ class BodyStructure:
|
||||
self.attachments += [params]
|
||||
|
||||
def load_from_definition(
|
||||
self, definition: list, multisubtype: Optional[str] = None
|
||||
self, definition: list, multisubtype: str | None = None
|
||||
) -> None:
|
||||
for mp in definition:
|
||||
if isinstance(mp, list):
|
||||
@@ -148,11 +147,11 @@ class IMAPconnector:
|
||||
unseen_pattern = re.compile(r"[^\(]+\(UNSEEN (\d+)\)")
|
||||
|
||||
def __init__(self, user: str, password: str, with_namespaces: bool = True) -> None:
|
||||
self.__hdelimiter: Optional[str] = None
|
||||
self.__hdelimiter: str | None = None
|
||||
self.__ns_prefixes: dict = {}
|
||||
self.quota_usage: int = -1
|
||||
self.quota_limit: Optional[int] = None
|
||||
self.quota_current: Optional[int] = None
|
||||
self.quota_limit: int | None = None
|
||||
self.quota_current: int | None = None
|
||||
self.criterions: list = []
|
||||
self.conf = dict(param_tools.get_global_parameters("webmail"))
|
||||
self.address = self.conf["imap_server"]
|
||||
@@ -170,7 +169,7 @@ class IMAPconnector:
|
||||
def __exit__(self, *args):
|
||||
self.logout()
|
||||
|
||||
def _cmd(self, name: str, *args, **kwargs) -> Optional[list]:
|
||||
def _cmd(self, name: str, *args, **kwargs) -> list | None:
|
||||
"""IMAP command wrapper.
|
||||
|
||||
To simplify errors handling, this wrapper calls the
|
||||
@@ -240,7 +239,8 @@ class IMAPconnector:
|
||||
except (OSError, imaplib.IMAP4.error, ssl.SSLError) as error:
|
||||
raise ImapError(_(f"Connection to IMAP server failed: {error}")) from None
|
||||
|
||||
if not settings.WEBMAIL_DEV_MODE:
|
||||
dev_mode = getattr(settings, "WEBMAIL_DEV_MODE", False)
|
||||
if not dev_mode:
|
||||
token = oauth2.build_oauthbearer_string(user, rawtoken)
|
||||
data = self._cmd("AUTHENTICATE", b"OAUTHBEARER", token)
|
||||
else:
|
||||
@@ -600,7 +600,7 @@ class IMAPconnector:
|
||||
self.select_mailbox(mbox, False)
|
||||
self._cmd("EXPUNGE")
|
||||
|
||||
def create_folder(self, name: str, parent: Optional[str] = None) -> bool:
|
||||
def create_folder(self, name: str, parent: str | None = None) -> bool:
|
||||
if parent is not None:
|
||||
name = f"{parent}{self.hdelimiter}{name}"
|
||||
typ, data = self.m.create(self._encode_mbox_name(name))
|
||||
@@ -675,7 +675,7 @@ class IMAPconnector:
|
||||
return attdef, data[int(uid)][f"BODY[{partnum}]"]
|
||||
|
||||
def fetch(
|
||||
self, start: int, stop: Optional[int] = None, mbox: Optional[str] = None
|
||||
self, start: int, stop: int | None = None, mbox: str | None = None
|
||||
) -> list:
|
||||
"""Retrieve information about messages from the server.
|
||||
|
||||
@@ -748,7 +748,7 @@ class IMAPconnector:
|
||||
return data[int(mailid)]
|
||||
|
||||
|
||||
def separate_mailbox(fullname: str, sep: str = ".") -> tuple[str, Optional[str]]:
|
||||
def separate_mailbox(fullname: str, sep: str = ".") -> tuple[str, str | None]:
|
||||
"""Split a mailbox name.
|
||||
|
||||
If a separator is found in ``fullname``, this function returns the
|
||||
|
||||
@@ -3,7 +3,6 @@ from email.mime.image import MIMEImage
|
||||
import os
|
||||
import pkg_resources
|
||||
import smtplib
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
import lxml
|
||||
@@ -20,7 +19,7 @@ from modoboa.webmail.lib.utils import html2plaintext
|
||||
from . import get_imapconnector
|
||||
|
||||
|
||||
def make_body_images_inline(body: str) -> Tuple[str, list]:
|
||||
def make_body_images_inline(body: str) -> tuple[str, list]:
|
||||
"""Look for images inside the body and make them inline.
|
||||
|
||||
Before sending a message in HTML format, it is necessary to find
|
||||
@@ -117,9 +116,7 @@ def create_message(user: core_models.User, attributes: dict, attachments: list):
|
||||
return msg
|
||||
|
||||
|
||||
def send_mail(
|
||||
request, attributes: dict, attachments: list
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
def send_mail(request, attributes: dict, attachments: list) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Send a new email.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user