diff --git a/backend/core/api/base/modal.py b/backend/core/api/base/modal.py index 0a6225ad..4814d84b 100644 --- a/backend/core/api/base/modal.py +++ b/backend/core/api/base/modal.py @@ -132,7 +132,7 @@ def open_modal(request: WebRequest, modal_name, context_type=None, context_value else: context[context_type] = context_value - if modal_name == "send_single_email" or modal_name == "send_bulk_email": + if modal_name == "send_single_email" or modal_name == "send_bulk_email" or modal_name == "send_email_from_invoice": if not get_feature_status("areUserEmailsAllowed"): messages.error(request, "Emails are disabled") return render(request, "base/toast.html") @@ -140,6 +140,7 @@ def open_modal(request: WebRequest, modal_name, context_type=None, context_value quota = QuotaLimit.objects.prefetch_related("quota_overrides").get(slug="emails-email_character_count") context["content_max_length"] = quota.get_quota_limit(user=request.user, quota_limit=quota) context["email_list"] = Client.filter_by_owner(owner=request.actor).filter(email__isnull=False).values_list("email", flat=True) + context["invoice_url"] = context_value if context_type == "invoice_code_send": invoice_url: InvoiceURL | None = InvoiceURL.objects.filter(uuid=context_value).prefetch_related("invoice").first() @@ -157,8 +158,7 @@ def open_modal(request: WebRequest, modal_name, context_type=None, context_value if value is not None ] - context["email_list"] = list(context["email_list"]) + context["selected_clients"] - + context["email_list"] = list(filter(lambda i: i is not "", list(context["email_list"]) + context["selected_clients"])) elif modal_name == "invoices_to_destination": if existing_client := request.GET.get("client"): context["existing_client_id"] = existing_client diff --git a/backend/core/api/emails/send.py b/backend/core/api/emails/send.py index 317a59ad..1052cb0e 100644 --- a/backend/core/api/emails/send.py +++ b/backend/core/api/emails/send.py @@ -13,11 +13,12 @@ from django.http import HttpResponse from django.shortcuts import render from django.views.decorators.http import require_POST -from mypy_boto3_sesv2.type_defs import BulkEmailEntryResultTypeDef +from mypy_boto3_sesv2.type_defs import BulkEmailEntryResultTypeDef, SendEmailResponseTypeDef from backend.core.data.default_email_templates import email_footer from backend.decorators import feature_flag_check, web_require_scopes from backend.decorators import htmx_only +from backend.finance.models import Invoice, InvoiceURL from backend.models import Client from backend.models import EmailSendStatus from backend.models import QuotaLimit @@ -65,6 +66,181 @@ def send_bulk_email_view(request: WebRequest) -> HttpResponse: return _send_bulk_email_view(request) +@require_POST +@htmx_only("emails:dashboard") +@feature_flag_check("areUserEmailsAllowed", status=True, api=True, htmx=True) +@web_require_scopes("emails:send", False, False, "emails:dashboard") +def send_invoice_email_view(request: WebRequest, uuid) -> HttpResponse: + # email_count = len(request.POST.getlist("emails")) - 1 + + # check_usage = quota_usage_check_under(request, "emails-single-count", add=email_count, api=True, htmx=True) + # if not isinstance(check_usage, bool): + # return check_usage + return _send_invoice_email_view(request, uuid) + + +def _send_invoice_email_view(request: WebRequest, uuid) -> HttpResponse: + emails: list[str] = request.POST.getlist("emails") + subject: str = request.POST.get("subject", "") + message: str = request.POST.get("content", "") + cc_emails = request.POST.get("cc_emails", "").split(",") if request.POST.get("cc_emails") else [] + bcc_emails = request.POST.get("bcc_emails", "").split(",") if request.POST.get("bcc_emails") else [] + invoiceurl_uuid = uuid + invoice_id = InvoiceURL.objects.filter(uuid=invoiceurl_uuid).values_list("invoice_id", flat=True).first() + + if invoice_id is None: + messages.error(request, "Invalid Invoice") + return render(request, "base/toast.html") + + invoice = Invoice.objects.filter(id=invoice_id).first() + + item_names = invoice.items.values_list("name", flat=True) + item_names_list = list(item_names) + + if request.user.logged_in_as_team: + clients = Client.objects.filter(organization=request.user.logged_in_as_team, email__in=emails) + else: + clients = Client.objects.filter(user=request.user, email__in=emails) + + validated_bulk = validate_bulk_inputs(request=request, emails=emails, clients=clients, message=message, subject=subject) + + if validated_bulk: + messages.error(request, validated_bulk) + return render(request, "base/toast.html") + + message += email_footer() + message_single_line_html = message.replace("\r\n", "
").replace("\n", "
") + + email_list: list[BulkEmailEmailItem] = [] + + for email in emails: + client = clients.filter(email=email).first() + + email_data = { + "first_name": invoice.client_name if invoice.client_name else "User", + "invoice_id": invoice.id, + "invoice_ref": invoice.reference or invoice.id, + "due_date": invoice.date_due.strftime("%A, %B %d, %Y"), + "amount_due": invoice.get_total_price(), + "currency": invoice.currency, + "currency_symbol": invoice.get_currency_symbol(), + "product_list": "\n".join(f"- {item_name}" for item_name in item_names_list), + "company_name": invoice.self_company or invoice.self_name or "MyFinances Customer", + "invoice_link": get_var("SITE_URL") + "/invoice/" + invoiceurl_uuid, + } + + email_list.append( + BulkEmailEmailItem( + destination=email, + cc=cc_emails, + bcc=bcc_emails, + template_data={ + "users_name": client.name.split()[0] if client else "User", + "content_text": Template(message).safe_substitute(email_data), + "content_html": Template(message_single_line_html).safe_substitute(email_data), + }, + ) + ) + + if get_var("DEBUG", "").lower() == "false": + print( + { + "email_list": email_list, + "template_name": "user_send_client_email", + "default_template_data": { + "sender_name": request.user.first_name or request.user.email, + "sender_id": request.user.id, + "subject": subject, + }, + }, + ) + messages.success(request, f"Successfully emailed {len(email_list)} people.") + return render(request, "base/toast.html") + + EMAIL_SENT = send_email( + destination=emails, + subject=subject, + content={ + "template_name": "user_send_client_email", + "template_data": { + "subject": subject, + "sender_name": request.user.first_name or request.user.email, + "sender_id": request.user.id, + "content_text": Template(message).safe_substitute(email_data), + "content_html": Template(message_single_line_html).safe_substitute(email_data), + }, + }, + from_address=request.user.email, + cc=cc_emails, + bcc=bcc_emails, + ) + + if EMAIL_SENT.failed: + messages.error(request, EMAIL_SENT.error) + return render(request, "base/toast.html") + + EMAIL_RESPONSES: Iterator[tuple[str, SendEmailResponseTypeDef]] = ( + ( + email_item.destination, + send_email( + destination=email_item.destination, + subject=subject, + content=email_item.template_data["content_text"], # type: ignore[index] + from_address=request.user.email, + cc=email_item.cc, + bcc=email_item.bcc, + ).response, + ) + for email_item in email_list + ) + + if request.user.logged_in_as_team: + SEND_STATUS_OBJECTS: list[EmailSendStatus] = EmailSendStatus.objects.bulk_create( + [ + EmailSendStatus( + organization=request.user.logged_in_as_team, + sent_by=request.user, + recipient=email_address, + aws_message_id=response.get("MessageId"), + status="pending", + ) + for email_address, response in EMAIL_RESPONSES + if response + ] + ) + else: + SEND_STATUS_OBJECTS = EmailSendStatus.objects.bulk_create( + [ + EmailSendStatus( + user=request.user, + sent_by=request.user, + recipient=email_address, + aws_message_id=response.get("MessageId"), + status="pending", + ) + for email_address, response in EMAIL_RESPONSES + if response + ] + ) + + messages.success(request, f"Successfully emailed {len(email_list)} people.") + + try: + quota_limits = QuotaLimit.objects.filter(slug__in=["emails-single-count", "emails-bulk-count"]) + + QuotaUsage.objects.bulk_create( + [ + QuotaUsage(user=request.user, quota_limit=quota_limits.get(slug="emails-single-count"), extra_data=status.id) + for status in SEND_STATUS_OBJECTS + ] + + [QuotaUsage(user=request.user, quota_limit=quota_limits.get(slug="emails-bulk-count"))] + ) + except QuotaLimit.DoesNotExist: + ... + + return render(request, "base/toast.html") + + def _send_bulk_email_view(request: WebRequest) -> HttpResponse: emails: list[str] = request.POST.getlist("emails") subject: str = request.POST.get("subject", "") @@ -120,7 +296,7 @@ def _send_bulk_email_view(request: WebRequest) -> HttpResponse: "sender_id": request.user.id, "subject": subject, }, - } + }, ) messages.success(request, f"Successfully emailed {len(email_list)} people.") return render(request, "base/toast.html") diff --git a/backend/core/api/emails/urls.py b/backend/core/api/emails/urls.py index 0f5217e7..dd6a80d4 100644 --- a/backend/core/api/emails/urls.py +++ b/backend/core/api/emails/urls.py @@ -15,6 +15,11 @@ send.send_bulk_email_view, name="send bulk", ), + path( + "send/invoice/", + send.send_invoice_email_view, + name="send invoice", + ), path("fetch/", fetch.fetch_all_emails, name="fetch"), path("get_status//", status.get_status_view, name="get_status"), path("refresh_statuses/", status.refresh_all_statuses_view, name="refresh statuses"), diff --git a/backend/core/data/default_email_templates.py b/backend/core/data/default_email_templates.py index 511ab105..944c3856 100644 --- a/backend/core/data/default_email_templates.py +++ b/backend/core/data/default_email_templates.py @@ -64,8 +64,63 @@ def email_footer() -> str: """ Note: This is an automated email sent by MyFinances on behalf of '$company_name'. - If you believe this email is spam or fraudulent, please do not pay the invoice and report it to us immediately at report@myfinances.cloud. - Once reported, we will open a case for investigation. In some cases, eligible reports may qualify for a reward, determined on a case-by-case basis. + If you suspect this email is spam or fraudulent, please let us know immediately to report@myfinances.cloud and avoid making payments. """ ).strip() ) + + +def invoice_state_pending_template() -> str: + return dedent( + """ + Hi $first_name, + + Your invoice #$invoice_id is now available and is due by $due_date. Please make your payment at your earliest convenience. + + Balance Due: $currency_symbol$amount_due $currency + View or Pay Online: $invoice_link + If you are paying by standing order, no further action is required. Should you have any questions or concerns, feel free to reach out to us. + + Thank you for your prompt attention to this matter. + + Best regards, + $company_name + """ + ).strip() + + +def invoice_state_paid_template() -> str: + return dedent( + """ + Hi $first_name, + + The invoice #$invoice_id has just been paid. + + If you have any questions or concerns, please feel free to contact us. + + Many thanks, + $company_name + """ + ).strip() + + +def invoice_state_overdue_template() -> str: + return dedent( + """ + Hi $first_name, + + We wanted to remind you that invoice #$invoice_id is now overdue. Please arrange payment as soon as possible to ensure there’s no interruption in your service. If you’ve already made the payment, kindly disregard this message—our apologies for any confusion. + + Here are the details for your convenience: + + Balance Due: $currency_symbol$amount_due $currency + Due Date: $due_date + + If you have any questions or concerns, we’re happy to help. Please don’t hesitate to reach out. + + Thank you for your prompt attention to this matter. + + Warm regards, + $company_name + """ + ).strip() diff --git a/backend/core/signals/signals.py b/backend/core/signals/signals.py index 751a51d3..81ad329c 100644 --- a/backend/core/signals/signals.py +++ b/backend/core/signals/signals.py @@ -1,8 +1,13 @@ from __future__ import annotations +from string import Template + from django.core.cache import cache from django.core.cache.backends.redis import RedisCacheClient +from backend.core.data.default_email_templates import email_footer, invoice_state_overdue_template +from backend.finance.models import Invoice + cache: RedisCacheClient = cache from django.core.files.storage import default_storage from django.db.models.signals import pre_save, post_delete, post_save, pre_delete @@ -96,3 +101,29 @@ def send_welcome_email(sender, instance: User, created, **kwargs): email = send_email(destination=instance.email, subject="Welcome to MyFinances", content=email_message) # User.send_welcome_email(instance) + + +@receiver(post_save, sender=Invoice) +def send_overdue_invoice_email(sender, instance: Invoice, **kwargs): + if instance.dynamic_status == "overdue": + client_email = instance.client_to.email if instance.client_to else instance.client_email + if client_email: + message: str = invoice_state_overdue_template() + email_footer() + + user_data = { + "first_name": instance.client_to.name.split(" ")[0] if instance.client_to else instance.client_name, + "invoice_id": instance.id, + "due_date": instance.date_due.strftime("%A, %B %d, %Y"), + "amount_due": instance.get_total_price(), + "currency": instance.currency, + "currency_symbol": instance.get_currency_symbol(), + "company_name": instance.self_company or instance.self_name or "MyFinances Customer", + } + + output: str = Template(message).safe_substitute(user_data) + + send_email( + destination=client_email, + subject=f"Invoice #{instance.id} from {instance.self_company or instance.self_name} is overdue", + content=output, + ) diff --git a/backend/core/types/emails.py b/backend/core/types/emails.py index cb1abc08..15d289c7 100644 --- a/backend/core/types/emails.py +++ b/backend/core/types/emails.py @@ -25,6 +25,8 @@ class SingleEmailInput: ConfigurationSetName: str | None = None from_address: str | None = None from_address_name_prefix: str | None = None + cc: list[str] = field(default_factory=list) + bcc: list[str] = field(default_factory=list) @dataclass diff --git a/backend/finance/api/invoices/edit.py b/backend/finance/api/invoices/edit.py index 6357f3c3..042d5d61 100644 --- a/backend/finance/api/invoices/edit.py +++ b/backend/finance/api/invoices/edit.py @@ -1,14 +1,15 @@ from datetime import datetime -from typing import Literal - +from string import Template from django.contrib import messages from django.http import HttpRequest, JsonResponse, HttpResponse from django.shortcuts import render, redirect from django.views.decorators.http import require_http_methods, require_POST +from backend.core.data.default_email_templates import invoice_state_paid_template, invoice_state_pending_template, email_footer from backend.decorators import web_require_scopes from backend.finance.models import Invoice from backend.core.types.htmx import HtmxHttpRequest +from settings.helpers import send_email @require_http_methods(["POST"]) @@ -97,6 +98,35 @@ def change_status(request: HtmxHttpRequest, invoice_id: int, status: str) -> Htt invoice.set_status(status) + if status in ["pending", "paid"] and invoice.dynamic_status != "overdue": + client_email = invoice.client_to.email if invoice.client_to else invoice.client_email + if client_email: + message_template = invoice_state_pending_template() + subject = f"Invoice #{invoice.id} is now available" + + if status == "paid": + message_template = invoice_state_paid_template() + subject = f"Invoice #{invoice.id} has been paid" + + message_template += email_footer() + + email_data = { + "first_name": invoice.client_to.name.split(" ")[0] if invoice.client_to else invoice.client_name, + "invoice_id": invoice.id, + "status": status.capitalize(), + "amount_due": invoice.get_total_price(), + "currency_symbol": invoice.get_currency_symbol(), + "due_date": invoice.date_due.strftime("%A, %B %d, %Y"), + "company_name": invoice.self_company or invoice.self_name or "MyFinances Customer", + } + message = Template(message_template).safe_substitute(email_data) + + send_email( + destination=client_email, + subject=subject, + content=message, + ) + send_message(request, f"Invoice status been changed to {status}", success=True) return render(request, "pages/invoices/dashboard/_modify_payment_status.html", {"status": status, "invoice_id": invoice_id}) diff --git a/frontend/templates/modals/send_email_from_invoice.html b/frontend/templates/modals/send_email_from_invoice.html new file mode 100644 index 00000000..ca0e6ac0 --- /dev/null +++ b/frontend/templates/modals/send_email_from_invoice.html @@ -0,0 +1,97 @@ +{% component_block "modal" id="modal_send_invoice_email" start_open="true" title="Send Invoice Email" %} +{% fill "content" %} + +{% endfill %} +{% endcomponent_block %} diff --git a/frontend/templates/pages/invoices/single/manage_access/_table_row.html b/frontend/templates/pages/invoices/single/manage_access/_table_row.html index 7a0c31db..d508b4c8 100644 --- a/frontend/templates/pages/invoices/single/manage_access/_table_row.html +++ b/frontend/templates/pages/invoices/single/manage_access/_table_row.html @@ -15,11 +15,11 @@ diff --git a/infrastructure/aws/pulumi/emails.py b/infrastructure/aws/pulumi/emails.py index bb1d2937..2fbe6953 100644 --- a/infrastructure/aws/pulumi/emails.py +++ b/infrastructure/aws/pulumi/emails.py @@ -38,17 +38,15 @@ default_reminder_template_name = "Hi {{client_name}}" default_reminder_template_failure_to_pay = "Failure to pay the invoice may come with an added late fee depending on the merchant" default_reminder_template_footer = """ -Note: This is an automated email sent out by MyFinances on behalf of '{{company}}'. If you -believe this is spam or fraudulent please report it to us and DO NOT pay the invoice. Once a report has been made you will -have a case opened. +Note: This email was automatically send by MyFinances on behalf of '{{company}}'. If you suspect this email is spam or fraudulent, please let us know immediately and avoid making payments. """ default_reminder_overdue_content = "This is an automated email to let you know that your invoice #{{invoice_id}} is due TODAY." default_reminder_before_due_content = ( - "This is an automated email to let you know that your invoice #{{invoice_id}} is due in {{days}} " "days." + "This is an automated email to let you know that your invoice #{{invoice_id}} is due in {{days}} days." ) default_reminder_after_due_content = ( - "This is an automated email to let you know that your invoice #{{invoice_id}} is past due by {{" "days}} " "days." + "This is an automated email to let you know that your invoice #{{invoice_id}} is past due by {{days}} days." ) default_reminder_subject = "REMINDER | Invoice #{{invoice_id}}" diff --git a/settings/helpers.py b/settings/helpers.py index 2b225ad8..893305cb 100644 --- a/settings/helpers.py +++ b/settings/helpers.py @@ -26,7 +26,7 @@ # NEEDS REFACTOR -env = environ.Env(DEBUG=(bool, False)) +# env = environ.Env(DEBUG=(bool, False)) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) environ.Env.read_env(os.path.join(BASE_DIR, ".env")) env = environ.Env() @@ -91,6 +91,8 @@ def send_email( ConfigurationSetName: str | None = None, from_address: str | None = None, from_address_name_prefix: str | None = None, + cc: list[str] = [], + bcc: list[str] = [], ) -> SingleEmailSendServiceResponse: """ Args: @@ -107,6 +109,8 @@ def send_email( ConfigurationSetName=ConfigurationSetName, from_address=from_address, from_address_name_prefix=from_address_name_prefix, + cc=cc, + bcc=bcc, ) if get_var("DEBUG", "").lower() == "true":