From 8356b6180137d2df20d079fceaa202113cdb989a Mon Sep 17 00:00:00 2001 From: Trey <73353716+TreyWW@users.noreply.github.com> Date: Sat, 28 Sep 2024 19:57:46 +0100 Subject: [PATCH] Feature/email templates (#499) Added basic email templates implementation, whole email system need revamp though --- backend/api/base/modal.py | 43 ++++--- backend/api/emails/send.py | 59 +++++++--- .../api/invoices/create/set_destination.py | 2 +- backend/api/invoices/edit.py | 1 + backend/api/invoices/recurring/edit.py | 1 + .../recurring/generate_next_invoice_now.py | 12 +- backend/api/invoices/schedules/recurring.py | 0 backend/api/public/endpoints/Invoices/edit.py | 1 + backend/api/public/permissions.py | 5 + backend/api/settings/email_templates.py | 9 ++ backend/data/default_email_templates.py | 60 ++++++++++ ...ing_invoices_invoice_cancelled_and_more.py | 36 ++++++ backend/models.py | 29 ++++- backend/service/defaults/update.py | 1 + .../service/invoices/common/create/create.py | 1 + .../invoices/common/create/get_page.py | 4 +- .../invoices/common/emails/on_create.py | 38 +++--- .../recurring/generation/next_invoice.py | 45 +++++++- backend/service/invoices/single/create_url.py | 15 +++ .../service/invoices/single/get_invoice.py | 14 +++ backend/service/settings/view.py | 34 +++++- backend/types/emails.py | 4 +- backend/views/core/invoices/recurring/edit.py | 2 + backend/views/core/invoices/single/edit.py | 3 + .../core/invoices/single/manage_access.py | 41 +++---- backend/views/core/settings/view.py | 20 +++- backend/webhooks/invoices/recurring.py | 4 +- docs/user-guide/emails/templates/index.md | 42 +++++++ .../templates/base/topbar/+icon_dropdown.html | 2 +- .../modals/invoices_to_destination.html | 8 ++ .../templates/modals/send_bulk_email.html | 23 +++- .../templates/modals/send_single_email.html | 2 +- .../create/destinations/_to_destination.html | 2 + .../pages/invoices/dashboard/_fetch_body.html | 108 +++++++++--------- .../invoices/recurring/dashboard/manage.html | 2 +- .../recurring/manage/next_invoice_block.html | 1 + .../single/edit/edit_to_destination.html | 2 + .../single/manage_access/_table_row.html | 14 ++- .../single/manage_access/manage_access.html | 3 +- frontend/templates/pages/settings/main.html | 17 ++- .../pages/settings/pages/email_templates.html | 22 ++++ .../settings/email_templates/tabs.html | 60 ++++++++++ mkdocs.yml | 2 + settings/helpers.py | 56 ++++++++- tests/views/test_invoices.py | 1 + 45 files changed, 694 insertions(+), 157 deletions(-) delete mode 100644 backend/api/invoices/schedules/recurring.py create mode 100644 backend/api/settings/email_templates.py create mode 100644 backend/data/default_email_templates.py create mode 100644 backend/migrations/0063_defaultvalues_email_template_recurring_invoices_invoice_cancelled_and_more.py create mode 100644 backend/service/invoices/single/create_url.py create mode 100644 backend/service/invoices/single/get_invoice.py create mode 100644 docs/user-guide/emails/templates/index.md create mode 100644 frontend/templates/pages/settings/pages/email_templates.html create mode 100644 frontend/templates/pages/settings/settings/email_templates/tabs.html diff --git a/backend/api/base/modal.py b/backend/api/base/modal.py index 55921e97..34a22bdb 100644 --- a/backend/api/base/modal.py +++ b/backend/api/base/modal.py @@ -6,7 +6,7 @@ from backend.api.public.permissions import SCOPE_DESCRIPTIONS from backend.api.public.models import APIAuthToken -from backend.models import Client, Receipt, User +from backend.models import Client, Receipt, User, InvoiceURL from backend.models import Invoice from backend.models import QuotaLimit from backend.models import Organization @@ -67,18 +67,18 @@ def open_modal(request: WebRequest, modal_name, context_type=None, context_value if invoice.client_to: context["to_name"] = invoice.client_to.name context["to_company"] = invoice.client_to.company + context["to_email"] = invoice.client_to.email context["to_address"] = invoice.client_to.address - context["existing_client_id"] = invoice.client_to.id - # context["to_city"] = invoice.client_to.city - # context["to_county"] = invoice.client_to.county - # context["to_country"] = invoice.client_to.country + context["existing_client_id"] = ( + invoice.client_to.id + ) # context["to_city"] = invoice.client_to.city # context["to_county"] = invoice.client_to.county # context["to_country"] = invoice.client_to.country else: context["to_name"] = invoice.client_name context["to_company"] = invoice.client_company - context["to_address"] = invoice.client_address - # context["to_city"] = invoice.client_city - # context["to_county"] = invoice.client_county - # context["to_country"] = invoice.client_country + context["to_email"] = invoice.client_email + context["to_address"] = ( + invoice.client_address + ) # context["to_city"] = invoice.client_city # context["to_county"] = invoice.client_county # context["to_country"] = invoice.client_country elif context_type == "edit_invoice_from": invoice = context_value try: @@ -134,8 +134,7 @@ def open_modal(request: WebRequest, modal_name, context_type=None, context_value # above_quota_usage = False # quota_usage_check_under(request, "invoices-schedules", api=True, htmx=True) - # if not isinstance(above_quota_usage, bool): - # context["above_quota_usage"] = True + # if not isinstance(above_quota_usage, bool): # context["above_quota_usage"] = True else: context[context_type] = context_value @@ -147,8 +146,26 @@ def open_modal(request: WebRequest, modal_name, context_type=None, context_value context["content_min_length"] = 64 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) - clients = Client.filter_by_owner(owner=request.actor).filter(email__isnull=False) - context["email_list"] = clients + context["email_list"] = Client.filter_by_owner(owner=request.actor).filter(email__isnull=False).values_list("email", flat=True) + + if context_type == "invoice_code_send": + invoice_url: InvoiceURL | None = InvoiceURL.objects.filter(uuid=context_value).prefetch_related("invoice").first() + + if not invoice_url or not invoice_url.invoice.has_access(request.user): + messages.error(request, "You don't have access to this invoice") + return render(request, "base/toast.html", {"autohide": False}) + + context["invoice"] = invoice_url.invoice + context["selected_clients"] = [ + invoice_url.invoice.client_to.email if invoice_url.invoice.client_to else invoice_url.invoice.client_email + for value in [ + invoice_url.invoice.client_to.email if invoice_url.invoice.client_to else invoice_url.invoice.client_email + ] + if value is not None + ] + + context["email_list"] = 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/api/emails/send.py b/backend/api/emails/send.py index cc9fd579..f20e4290 100644 --- a/backend/api/emails/send.py +++ b/backend/api/emails/send.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from collections.abc import Iterator +from string import Template from django.contrib import messages from django.core.exceptions import ValidationError @@ -14,6 +15,7 @@ from django.views.decorators.http import require_POST from mypy_boto3_sesv2.type_defs import BulkEmailEntryResultTypeDef +from backend.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.models import Client @@ -23,8 +25,9 @@ from backend.types.emails import ( BulkEmailEmailItem, ) +from backend.types.requests import WebRequest -from settings.helpers import send_email, send_templated_bulk_email +from settings.helpers import send_email, send_templated_bulk_email, get_var from backend.types.htmx import HtmxHttpRequest @@ -41,7 +44,7 @@ class Invalid: @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_single_email_view(request: HtmxHttpRequest) -> HttpResponse: +def send_single_email_view(request: WebRequest) -> HttpResponse: # check_usage = False # quota_usage_check_under(request, "emails-single-count", api=True, htmx=True) # if not isinstance(check_usage, bool): # return check_usage @@ -53,7 +56,7 @@ def send_single_email_view(request: HtmxHttpRequest) -> HttpResponse: @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_bulk_email_view(request: HtmxHttpRequest) -> HttpResponse: +def send_bulk_email_view(request: WebRequest) -> 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) @@ -62,10 +65,12 @@ def send_bulk_email_view(request: HtmxHttpRequest) -> HttpResponse: return _send_bulk_email_view(request) -def _send_bulk_email_view(request: HtmxHttpRequest) -> HttpResponse: +def _send_bulk_email_view(request: WebRequest) -> HttpResponse: emails: list[str] = request.POST.getlist("emails") subject: str = request.POST.get("subject", "") message: str = request.POST.get("content", "") + cc_yourself = True if request.POST.get("cc_yourself") else False + bcc_yourself = True if request.POST.get("bcc_yourself") else False if request.user.logged_in_as_team: clients = Client.objects.filter(organization=request.user.logged_in_as_team, email__in=emails) @@ -78,23 +83,48 @@ def _send_bulk_email_view(request: HtmxHttpRequest) -> HttpResponse: 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.get(email=email) + client = clients.filter(email=email).first() + + email_data = { + "users_name": client.name.split()[0] if client else "User", + "first_name": client.name.split()[0] if client else "User", + "company_name": request.actor.name, + } # todo: add all variables from https://docs.myfinances.cloud/user-guide/emails/templates/ + email_list.append( BulkEmailEmailItem( destination=email, + cc=[request.user.email] if cc_yourself else [], + bcc=[request.user.email] if bcc_yourself else [], template_data={ - "users_name": client.name.split()[0], - "content_text": message.format(users_name=client.name.split()[0]), - "content_html": message_single_line_html.format(users_name=client.name.split()[0]), + "users_name": client.name.split()[0] if client else "User", + "content_text": Template(message).substitute(email_data), + "content_html": Template(message_single_line_html).substitute(email_data), }, ) ) + if get_var("DEBUG", "").lower() == "true": + 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_templated_bulk_email( email_list=email_list, template_name="user_send_client_email", @@ -160,7 +190,7 @@ def _send_bulk_email_view(request: HtmxHttpRequest) -> HttpResponse: return render(request, "base/toast.html") -def _send_single_email_view(request: HtmxHttpRequest) -> HttpResponse: +def _send_single_email_view(request: WebRequest) -> HttpResponse: email: str = str(request.POST.get("email", "")).strip() subject: str = request.POST.get("subject", "") message: str = request.POST.get("content", "") @@ -176,8 +206,11 @@ def _send_single_email_view(request: HtmxHttpRequest) -> HttpResponse: messages.error(request, validated_single) return render(request, "base/toast.html") + message += email_footer() message_single_line_html = message.replace("\r\n", "
").replace("\n", "
") + email_data = {"company_name": request.actor.name} + EMAIL_SENT = send_email( destination=email, subject=subject, @@ -187,8 +220,8 @@ def _send_single_email_view(request: HtmxHttpRequest) -> HttpResponse: "subject": subject, "sender_name": request.user.first_name or request.user.email, "sender_id": request.user.id, - "content_text": message, - "content_html": message_single_line_html, + "content_text": Template(message).substitute(email_data), + "content_html": Template(message_single_line_html).substitute(email_data), }, }, ) @@ -222,7 +255,7 @@ def validate_bulk_inputs(*, request, emails, clients, message, subject) -> str | def run_validations(): yield validate_bulk_quotas(request=request, emails=emails) yield validate_email_list(emails=emails) - yield validate_client_list(clients=clients, emails=emails) + # yield validate_client_list(clients=clients, emails=emails) yield validate_email_content(message=message, request=request) yield validate_email_subject(subject=subject) @@ -306,8 +339,6 @@ def validate_email_list(emails: list[str]) -> str | None: def validate_client_list(clients: QuerySet[Client], emails: list[str]) -> str | None: for email in emails: if not clients.filter(email=email).exists(): - # if not client.email_verified: - # return f"Client {email} isn't yet verified so we can't send them an email yet!" return f"Could not find client object for {email}" return None diff --git a/backend/api/invoices/create/set_destination.py b/backend/api/invoices/create/set_destination.py index 427854f7..cd19ba25 100644 --- a/backend/api/invoices/create/set_destination.py +++ b/backend/api/invoices/create/set_destination.py @@ -5,7 +5,7 @@ from backend.models import Client from backend.types.htmx import HtmxHttpRequest -to_get = ["name", "address", "city", "country", "company", "is_representative"] +to_get = ["name", "address", "city", "country", "company", "is_representative", "email"] @require_http_methods(["POST"]) diff --git a/backend/api/invoices/edit.py b/backend/api/invoices/edit.py index 11f8b025..f6cef525 100644 --- a/backend/api/invoices/edit.py +++ b/backend/api/invoices/edit.py @@ -34,6 +34,7 @@ def edit_invoice(request: HtmxHttpRequest): "date_issued": request.POST.get("date_issued"), "client_name": request.POST.get("to_name"), "client_company": request.POST.get("to_company"), + "client_email": request.POST.get("to_email"), "client_address": request.POST.get("to_address"), "client_city": request.POST.get("to_city"), "client_county": request.POST.get("to_county"), diff --git a/backend/api/invoices/recurring/edit.py b/backend/api/invoices/recurring/edit.py index 8cd3f15c..351c1656 100644 --- a/backend/api/invoices/recurring/edit.py +++ b/backend/api/invoices/recurring/edit.py @@ -41,6 +41,7 @@ def edit_invoice_recurring_profile_endpoint(request: WebRequest, invoice_profile "date_issued": request.POST.get("date_issued"), "client_name": request.POST.get("to_name"), "client_company": request.POST.get("to_company"), + "client_email": request.POST.get("to_email"), "client_address": request.POST.get("to_address"), "client_city": request.POST.get("to_city"), "client_county": request.POST.get("to_county"), diff --git a/backend/api/invoices/recurring/generate_next_invoice_now.py b/backend/api/invoices/recurring/generate_next_invoice_now.py index b6e46c0e..58cb77ab 100644 --- a/backend/api/invoices/recurring/generate_next_invoice_now.py +++ b/backend/api/invoices/recurring/generate_next_invoice_now.py @@ -5,7 +5,7 @@ from backend.decorators import web_require_scopes, htmx_only from backend.models import InvoiceRecurringProfile, Invoice from backend.service.defaults.get import get_account_defaults -from backend.service.invoices.recurring.generation.next_invoice import generate_next_invoice_service +from backend.service.invoices.recurring.generation.next_invoice import safe_generate_next_invoice_service from backend.types.requests import WebRequest import logging @@ -24,7 +24,7 @@ def generate_next_invoice_now_endpoint(request: WebRequest, invoice_profile_id): if not invoice_recurring_profile: messages.error(request, "Failed to fetch next invoice; cannot find Invoice recurring profile.") - return render(request, "base/toast.html") + return render(request, "base/toast.html", {"autohide": False}) if invoice_recurring_profile.client_to: account_defaults = get_account_defaults(invoice_recurring_profile.owner, invoice_recurring_profile.client_to) @@ -33,11 +33,11 @@ def generate_next_invoice_now_endpoint(request: WebRequest, invoice_profile_id): if not invoice_recurring_profile.has_access(request.user): messages.error(request, "You do not have permission to modify this invoice recurring profile.") - return render(request, "base/toast.html") + return render(request, "base/toast.html", {"autohide": False}) next_invoice_issue_date = invoice_recurring_profile.next_invoice_issue_date() - svc_resp = generate_next_invoice_service( + svc_resp = safe_generate_next_invoice_service( invoice_recurring_profile=invoice_recurring_profile, issue_date=next_invoice_issue_date, account_defaults=account_defaults ) @@ -58,5 +58,5 @@ def generate_next_invoice_now_endpoint(request: WebRequest, invoice_profile_id): ) else: logger.info(svc_resp.error) - messages.error(request, "Failed to fetch next invoice; cannot find invoice recurring profile.") - return render(request, "base/toast.html") + messages.error(request, f"Failed to fetch next invoice; {svc_resp.error}") + return render(request, "base/toast.html", {"autohide": False}) diff --git a/backend/api/invoices/schedules/recurring.py b/backend/api/invoices/schedules/recurring.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/api/public/endpoints/Invoices/edit.py b/backend/api/public/endpoints/Invoices/edit.py index 5ac882f8..42abcf0c 100644 --- a/backend/api/public/endpoints/Invoices/edit.py +++ b/backend/api/public/endpoints/Invoices/edit.py @@ -36,6 +36,7 @@ def edit_invoice_endpoint(request: APIRequest): "date_issued": request.POST.get("date_issued"), "client_name": request.POST.get("to_name"), "client_company": request.POST.get("to_company"), + "client_email": request.POST.get("to_email"), "client_address": request.POST.get("to_address"), "client_city": request.POST.get("to_city"), "client_county": request.POST.get("to_county"), diff --git a/backend/api/public/permissions.py b/backend/api/public/permissions.py index c95b4965..69beaa4b 100644 --- a/backend/api/public/permissions.py +++ b/backend/api/public/permissions.py @@ -21,6 +21,8 @@ "team_permissions:write", "team:invite", "team:kick", + "email_templates:read", + "email_templates:write", } SCOPES_TREE = { @@ -36,6 +38,8 @@ "team_permissions:write": {"team_permissions:read", "team_permissions:write"}, "team:invite": {"team:invite"}, "team:kick": {"team:kick", "team:invite"}, + "email_templates:read": {"email_templates:read"}, + "email_templates:write": {"email_templates:read", "email_templates:write"}, } SCOPE_DESCRIPTIONS = { @@ -45,6 +49,7 @@ "api_keys": {"description": "Access API keys", "options": {"read": "Read only", "write": "Read and write"}}, "team_permissions": {"description": "Access team permissions", "options": {"read": "Read only", "write": "Read and write"}}, "team": {"description": "Invite team members", "options": {"invite": "Invite members"}}, + "email_templates": {"description": "Access email templates", "options": {"read": "Read only", "write": "Read and write"}}, } if settings.BILLING_ENABLED: diff --git a/backend/api/settings/email_templates.py b/backend/api/settings/email_templates.py new file mode 100644 index 00000000..b28c8e64 --- /dev/null +++ b/backend/api/settings/email_templates.py @@ -0,0 +1,9 @@ +from django.views.decorators.http import require_GET + +from backend.decorators import web_require_scopes +from backend.types.requests import WebRequest + + +@require_GET +@web_require_scopes("email_templates:read") +def get_current_email_template(request: WebRequest): ... diff --git a/backend/data/default_email_templates.py b/backend/data/default_email_templates.py new file mode 100644 index 00000000..03c431c5 --- /dev/null +++ b/backend/data/default_email_templates.py @@ -0,0 +1,60 @@ +from textwrap import dedent + + +def recurring_invoices_invoice_created_default_email_template() -> str: + return dedent( + """ + Hi $first_name, + + The invoice #$invoice_id has been created for you to pay, due on the $due_date. Please pay at your earliest convenience. + + Balance Due: $currency_symbol$amount_due $currency + + Many thanks, + $company_name + """ + ).strip() + + +def recurring_invoices_invoice_overdue_default_email_template() -> str: + return dedent( + """ + Hi $first_name, + + The invoice #$invoice_id is now overdue. Please pay as soon as possible to avoid any interruptions in your service or late fees. + + Balance Due: $currency_symbol$amount_due $currency + + Many thanks, + $company_name + """ + ).strip() + + +def recurring_invoices_invoice_cancelled_default_email_template() -> str: + return dedent( + """ + Hi $first_name, + + The invoice #$invoice_id has been cancelled. You do not have to pay the invoice. + + If you have any questions or concerns, please feel free to contact us. + + Many thanks, + $company_name + """ + ).strip() + + +def email_footer() -> str: + return ( + "\n" + + dedent( + """ +Note: This is an automated email sent out by MyFinances on behalf of '$company_name'. + +If you believe this is spam or fraudulent please report it to us at report@myfinances.cloud and DO NOT pay the invoice. +Once a report has been made you will have a case opened. Eligible reports may receive a reward, decided on a case by case basis. +""" + ).strip() + ) diff --git a/backend/migrations/0063_defaultvalues_email_template_recurring_invoices_invoice_cancelled_and_more.py b/backend/migrations/0063_defaultvalues_email_template_recurring_invoices_invoice_cancelled_and_more.py new file mode 100644 index 00000000..5aefa7ed --- /dev/null +++ b/backend/migrations/0063_defaultvalues_email_template_recurring_invoices_invoice_cancelled_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 5.1 on 2024-09-28 18:46 + +import backend.data.default_email_templates +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("backend", "0062_defaultvalues_invoice_account_holder_name_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="defaultvalues", + name="email_template_recurring_invoices_invoice_cancelled", + field=models.TextField( + default=backend.data.default_email_templates.recurring_invoices_invoice_cancelled_default_email_template + ), + ), + migrations.AddField( + model_name="defaultvalues", + name="email_template_recurring_invoices_invoice_created", + field=models.TextField(default=backend.data.default_email_templates.recurring_invoices_invoice_created_default_email_template), + ), + migrations.AddField( + model_name="defaultvalues", + name="email_template_recurring_invoices_invoice_overdue", + field=models.TextField(default=backend.data.default_email_templates.recurring_invoices_invoice_overdue_default_email_template), + ), + migrations.AddField( + model_name="defaultvalues", + name="invoice_from_email", + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/backend/models.py b/backend/models.py index 3a8e6e2f..60d7117f 100644 --- a/backend/models.py +++ b/backend/models.py @@ -19,6 +19,12 @@ from shortuuid.django_fields import ShortUUIDField from storages.backends.s3 import S3Storage +from backend.data.default_email_templates import ( + recurring_invoices_invoice_created_default_email_template, + recurring_invoices_invoice_overdue_default_email_template, + recurring_invoices_invoice_cancelled_default_email_template, +) + from backend.managers import InvoiceRecurringProfile_WithItemsManager @@ -95,6 +101,10 @@ class Role(models.TextChoices): role = models.CharField(max_length=10, choices=Role.choices, default=Role.USER) + @property + def name(self): + return self.first_name + def add_3hrs_from_now(): return timezone.now() + timezone.timedelta(hours=3) @@ -406,11 +416,18 @@ class InvoiceDateType(models.TextChoices): invoice_from_city = models.CharField(max_length=100, null=True, blank=True) invoice_from_county = models.CharField(max_length=100, null=True, blank=True) invoice_from_country = models.CharField(max_length=100, null=True, blank=True) + invoice_from_email = models.CharField(max_length=100, null=True, blank=True) invoice_account_number = models.CharField(max_length=100, null=True, blank=True) invoice_sort_code = models.CharField(max_length=100, null=True, blank=True) invoice_account_holder_name = models.CharField(max_length=100, null=True, blank=True) + email_template_recurring_invoices_invoice_created = models.TextField(default=recurring_invoices_invoice_created_default_email_template) + email_template_recurring_invoices_invoice_overdue = models.TextField(default=recurring_invoices_invoice_overdue_default_email_template) + email_template_recurring_invoices_invoice_cancelled = models.TextField( + default=recurring_invoices_invoice_cancelled_default_email_template + ) + def get_issue_and_due_dates(self, issue_date: date | str | None = None) -> tuple[str, str]: due: date issue: date @@ -606,10 +623,7 @@ def get_to_details(self) -> tuple[str, dict[str, str | None]] | tuple[str, Clien if self.client_to: return "client", self.client_to else: - return "manual", { - "name": self.client_name, - "company": self.client_company, - } + return "manual", {"name": self.client_name, "company": self.client_company, "email": self.client_email} def get_subtotal(self) -> Decimal: subtotal = 0 @@ -723,6 +737,13 @@ class InvoiceURL(models.Model): never_expire = models.BooleanField(default=False) active = models.BooleanField(default=True) + @property + def get_created_by(self): + if self.created_by: + return self.created_by.first_name or f"USR #{self.created_by.id}" + else: + return "SYSTEM" + def is_active(self): if not self.active: return False diff --git a/backend/service/defaults/update.py b/backend/service/defaults/update.py index 10dea69e..17618247 100644 --- a/backend/service/defaults/update.py +++ b/backend/service/defaults/update.py @@ -46,6 +46,7 @@ def change_client_defaults(request: WebRequest, defaults: DefaultValues) -> Clie DETAIL_INPUTS = { "name": {"max_len": 100}, + "email": {"max_len": 100}, "company": {"max_len": 100}, "address": {"max_len": 100}, "city": {"max_len": 100}, diff --git a/backend/service/invoices/common/create/create.py b/backend/service/invoices/common/create/create.py index cfcf48c3..fbd0bf58 100644 --- a/backend/service/invoices/common/create/create.py +++ b/backend/service/invoices/common/create/create.py @@ -37,6 +37,7 @@ def save_invoice_common(request: WebRequest, invoice_items, invoice: Invoice | I else: invoice.client_name = request.POST.get("to_name") invoice.client_company = request.POST.get("to_company") + invoice.client_email = request.POST.get("to_email") invoice.client_address = request.POST.get("to_address") invoice.client_city = request.POST.get("to_city") invoice.client_county = request.POST.get("to_county") diff --git a/backend/service/invoices/common/create/get_page.py b/backend/service/invoices/common/create/get_page.py index 2bd1ba02..810b22b2 100644 --- a/backend/service/invoices/common/create/get_page.py +++ b/backend/service/invoices/common/create/get_page.py @@ -36,7 +36,7 @@ def global_get_invoice_context(request: WebRequest) -> CreateInvoiceContextServi else: defaults = get_account_defaults(request.actor, client=None) - for item in ["name", "company", "address", "city", "county", "country"]: + for item in ["name", "company", "address", "city", "county", "country", "email"]: context[f"from_{item}"] = request.GET.get(f"from_{item}", "") if issue_date := request.GET.get("issue_date"): @@ -72,7 +72,7 @@ def global_get_invoice_context(request: WebRequest) -> CreateInvoiceContextServi if account_number := request.GET.get("account_number"): context["account_number"] = account_number - details_from = ["name", "company", "address", "city", "county", "country"] + details_from = ["name", "company", "address", "city", "county", "country", "email"] for detail in details_from: detail_value = request.GET.get(f"from_{detail}", "") diff --git a/backend/service/invoices/common/emails/on_create.py b/backend/service/invoices/common/emails/on_create.py index d2ab88c0..50b0243e 100644 --- a/backend/service/invoices/common/emails/on_create.py +++ b/backend/service/invoices/common/emails/on_create.py @@ -1,34 +1,37 @@ from string import Template from textwrap import dedent -from backend.models import Invoice, InvoiceRecurringProfile, User, EmailSendStatus +from django.urls import reverse + +from backend.data.default_email_templates import email_footer +from backend.models import Invoice, InvoiceRecurringProfile, User, EmailSendStatus, InvoiceURL +from backend.service.defaults.get import get_account_defaults +from backend.service.invoices.single.create_url import create_invoice_url from backend.utils.dataclasses import BaseServiceResponse from backend.utils.service_retry import retry_handler -from settings.helpers import send_email +from settings.helpers import send_email, get_var + +""" +DOCS: https://docs.myfinances.cloud/user-guide/emails/templates/ +(please update if any variables are changed) +""" -class OnCreateInvoiceEmailServiceResponse(BaseServiceResponse[str]): ... +class OnCreateInvoiceEmailServiceResponse(BaseServiceResponse[EmailSendStatus]): ... -def on_create_invoice_service(users_email: str, invoice: Invoice) -> OnCreateInvoiceEmailServiceResponse: +def on_create_invoice_email_service(users_email: str, invoice: Invoice) -> OnCreateInvoiceEmailServiceResponse: if not users_email: return OnCreateInvoiceEmailServiceResponse(error_message="User email not found") if not invoice: return OnCreateInvoiceEmailServiceResponse(error_message="Invoice not found") - email_message = dedent( - """ - Hi $first_name, + defaults = get_account_defaults(invoice.owner, invoice.client_to) - The invoice #$invoice_id has been created for you to pay, due on the $due_date. Please pay at your earliest convenience. + email_message: str = defaults.email_template_recurring_invoices_invoice_created + email_footer() - Balance Due: $currency_symbol$amount_due $currency - - Many thanks, - $company_name - """ - ) + invoice_url: InvoiceURL = create_invoice_url(invoice).response user_data = { "first_name": invoice.client_to.name.split(" ")[0] if invoice.client_to else invoice.client_name, @@ -39,7 +42,8 @@ def on_create_invoice_service(users_email: str, invoice: Invoice) -> OnCreateInv "currency": invoice.currency, "currency_symbol": invoice.get_currency_symbol(), "product_list": [], # todo - "company_name": invoice.self_company or invoice.self_name, + "company_name": invoice.self_company or invoice.self_name or "MyFinances Customer", + "invoice_link": get_var("SITE_URL") + reverse("invoices view invoice", kwargs={"uuid": str(invoice_url.uuid)}), } output: str = Template(email_message).substitute(user_data) @@ -54,11 +58,11 @@ def on_create_invoice_service(users_email: str, invoice: Invoice) -> OnCreateInv if email_svc_response.failed: return OnCreateInvoiceEmailServiceResponse(False, error_message="Failed to send email") - EmailSendStatus.objects.create( + email_status_obj = EmailSendStatus.objects.create( status="send", owner=invoice.owner, recipient=users_email, aws_message_id=email_svc_response.response.get("MessageId"), ) - return OnCreateInvoiceEmailServiceResponse(True, response="Email sent successfully") + return OnCreateInvoiceEmailServiceResponse(True, response=email_status_obj) diff --git a/backend/service/invoices/recurring/generation/next_invoice.py b/backend/service/invoices/recurring/generation/next_invoice.py index 4c747648..21f21299 100644 --- a/backend/service/invoices/recurring/generation/next_invoice.py +++ b/backend/service/invoices/recurring/generation/next_invoice.py @@ -1,6 +1,10 @@ from datetime import datetime, date, timedelta + +from django.db import transaction, IntegrityError + from backend.models import Invoice, InvoiceRecurringProfile, DefaultValues, AuditLog from backend.service.defaults.get import get_account_defaults +from backend.service.invoices.common.emails.on_create import on_create_invoice_email_service from backend.utils.dataclasses import BaseServiceResponse import logging @@ -11,6 +15,7 @@ class GenerateNextInvoiceServiceResponse(BaseServiceResponse[Invoice]): ... +@transaction.atomic def generate_next_invoice_service( invoice_recurring_profile: InvoiceRecurringProfile, issue_date: date = date.today(), @@ -76,10 +81,48 @@ def generate_next_invoice_service( logger.info(f"Invoice generated with the ID of {generated_invoice.pk}") + users_email: str = ( + invoice_recurring_profile.client_to.email if invoice_recurring_profile.client_to else invoice_recurring_profile.client_email + ) or "" + + invoice_email_response = on_create_invoice_email_service(users_email=users_email, invoice=generated_invoice) + + if invoice_email_response.failed: + print("here bef fail") + raise IntegrityError(f"Failed to send invoice #{generated_invoice.pk} to {users_email}: {invoice_email_response.error}") + AuditLog.objects.create( action=f"[SYSTEM] Generated invoice #{generated_invoice.pk} from the recurring profile #{invoice_recurring_profile.pk}", user=invoice_recurring_profile.user, organization=invoice_recurring_profile.organization, ) - return GenerateNextInvoiceServiceResponse(True, generated_invoice) + return GenerateNextInvoiceServiceResponse(True, response=generated_invoice) + + +def handle_invoice_generation_failure(invoice_recurring_profile, error_message): + """ + Function to handle invoice generation failure and log it in AuditLog. + This runs outside the atomic transaction to avoid rollback. + """ + AuditLog.objects.create( + action=f"[SYSTEM] Failed to generate invoice for recurring profile #{invoice_recurring_profile.pk}. Error: {error_message}", + ) + logger.error(f"Failed to generate invoice for profile {invoice_recurring_profile.pk}: {error_message}") + + +def safe_generate_next_invoice_service( + invoice_recurring_profile: InvoiceRecurringProfile, + issue_date: date = date.today(), + account_defaults: DefaultValues | None = None, +) -> GenerateNextInvoiceServiceResponse: + """ + Safe wrapper to generate the next invoice with transaction rollback and error logging. + """ + try: + # Call the main service function wrapped with @transaction.atomic + return generate_next_invoice_service(invoice_recurring_profile, issue_date, account_defaults) + except Exception as e: + # Handle the error and ensure the failure is logged + handle_invoice_generation_failure(invoice_recurring_profile, str(e)) + return GenerateNextInvoiceServiceResponse(False, error_message=str(e)) diff --git a/backend/service/invoices/single/create_url.py b/backend/service/invoices/single/create_url.py new file mode 100644 index 00000000..81234bdb --- /dev/null +++ b/backend/service/invoices/single/create_url.py @@ -0,0 +1,15 @@ +from backend.models import InvoiceURL, Invoice, User +from backend.utils.dataclasses import BaseServiceResponse + + +class CreateInvoiceURLServiceResponse(BaseServiceResponse[InvoiceURL]): ... + + +def create_invoice_url(invoice: Invoice, user: User | None = None) -> CreateInvoiceURLServiceResponse: + return CreateInvoiceURLServiceResponse( + True, + response=InvoiceURL.objects.create( + invoice=invoice, + created_by=user, + ), + ) diff --git a/backend/service/invoices/single/get_invoice.py b/backend/service/invoices/single/get_invoice.py new file mode 100644 index 00000000..80c58869 --- /dev/null +++ b/backend/service/invoices/single/get_invoice.py @@ -0,0 +1,14 @@ +from backend.models import Invoice, Organization, User +from backend.utils.dataclasses import BaseServiceResponse + + +class GetInvoiceServiceResponse(BaseServiceResponse[Invoice]): ... + + +def get_invoice_by_actor(actor: User | Organization, id: str | int, prefetch_related: list[str] | None = None) -> GetInvoiceServiceResponse: + prefetch_related_args: list[str] = prefetch_related or [] + try: + invoice: Invoice = Invoice.filter_by_owner(actor).prefetch_related(*prefetch_related_args).get(id=id) + return GetInvoiceServiceResponse(True, response=invoice) + except Invoice.DoesNotExist: + return GetInvoiceServiceResponse(False, error_message="Invoice not found") diff --git a/backend/service/settings/view.py b/backend/service/settings/view.py index 1bd93259..496ec8d4 100644 --- a/backend/service/settings/view.py +++ b/backend/service/settings/view.py @@ -3,11 +3,12 @@ from backend.models import UserSettings from backend.models import DefaultValues from backend.api.public.models import APIAuthToken +from backend.service.defaults.get import get_account_defaults from backend.types.requests import WebRequest def validate_page(page: str | None) -> bool: - return not page or page in ["profile", "account", "api_keys", "account_defaults", "account_security"] + return not page or page in ["profile", "account", "api_keys", "account_defaults", "account_security", "email_templates"] def get_user_profile(request: WebRequest) -> UserSettings: @@ -21,3 +22,34 @@ def get_user_profile(request: WebRequest) -> UserSettings: def get_api_keys(request: WebRequest) -> QuerySet[APIAuthToken]: return APIAuthToken.filter_by_owner(request.actor).filter(active=True).only("created", "name", "last_used", "description", "expires") + + +def account_page_context(request: WebRequest, context: dict) -> None: + user_profile = get_user_profile(request) + context.update({"currency_signs": user_profile.CURRENCIES, "currency": user_profile.currency}) + + +def api_keys_page_context(request: WebRequest, context: dict) -> None: + api_keys = get_api_keys(request) + context.update({"api_keys": api_keys}) + + +def account_defaults_context(request: WebRequest, context: dict) -> None: + context.update({"account_defaults": get_account_defaults(request.actor)}) + + +def email_templates_context(request: WebRequest, context: dict) -> None: + acc_defaults = get_account_defaults(request.actor) + context.update( + { + "account_defaults": acc_defaults, + "email_templates": { + "recurring_invoices": { + "invoice_created": acc_defaults.email_template_recurring_invoices_invoice_created, + "invoice_overdue": acc_defaults.email_template_recurring_invoices_invoice_overdue, + "invoice_cancelled": acc_defaults.email_template_recurring_invoices_invoice_cancelled, + } + }, + } + ) + print(context.get("email_templates")) diff --git a/backend/types/emails.py b/backend/types/emails.py index 3263acfe..1e80d268 100644 --- a/backend/types/emails.py +++ b/backend/types/emails.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TypedDict from mypy_boto3_sesv2.type_defs import SendEmailResponseTypeDef, SendBulkEmailResponseTypeDef, BulkEmailEntryResultTypeDef @@ -31,6 +31,8 @@ class SingleEmailInput: class BulkEmailEmailItem: destination: str template_data: dict | str + cc: list[str] = field(default_factory=list) + bcc: list[str] = field(default_factory=list) @dataclass(frozen=False) diff --git a/backend/views/core/invoices/recurring/edit.py b/backend/views/core/invoices/recurring/edit.py index e18fa6e6..6b85a72e 100644 --- a/backend/views/core/invoices/recurring/edit.py +++ b/backend/views/core/invoices/recurring/edit.py @@ -41,6 +41,7 @@ def invoice_get_existing_data(invoice_obj: InvoiceRecurringProfile): if invoice_obj.client_to: stored_data["to_name"] = invoice_obj.client_to.name stored_data["to_company"] = invoice_obj.client_to.company + stored_data["to_email"] = invoice_obj.client_to.email stored_data["is_representative"] = invoice_obj.client_to.is_representative # stored_data["to_address"] = invoice_obj.client_to.address # stored_data["to_city"] = invoice_obj.client_to.city @@ -49,6 +50,7 @@ def invoice_get_existing_data(invoice_obj: InvoiceRecurringProfile): else: stored_data["to_name"] = invoice_obj.client_name stored_data["to_company"] = invoice_obj.client_company + stored_data["to_email"] = invoice_obj.client_email stored_data["to_address"] = invoice_obj.client_address stored_data["to_city"] = invoice_obj.client_city stored_data["to_county"] = invoice_obj.client_county diff --git a/backend/views/core/invoices/single/edit.py b/backend/views/core/invoices/single/edit.py index 33b3ac95..b3bd891d 100644 --- a/backend/views/core/invoices/single/edit.py +++ b/backend/views/core/invoices/single/edit.py @@ -34,6 +34,7 @@ def invoice_get_existing_data(invoice_obj): if invoice_obj.client_to: stored_data["to_name"] = invoice_obj.client_to.name stored_data["to_company"] = invoice_obj.client_to.company + stored_data["to_email"] = invoice_obj.client_to.email stored_data["is_representative"] = invoice_obj.client_to.is_representative # stored_data["to_address"] = invoice_obj.client_to.address # stored_data["to_city"] = invoice_obj.client_to.city @@ -42,6 +43,7 @@ def invoice_get_existing_data(invoice_obj): else: stored_data["to_name"] = invoice_obj.client_name stored_data["to_company"] = invoice_obj.client_company + stored_data["to_email"] = invoice_obj.client_email stored_data["to_address"] = invoice_obj.client_address stored_data["to_city"] = invoice_obj.client_city stored_data["to_county"] = invoice_obj.client_county @@ -116,6 +118,7 @@ def edit_invoice(request: HtmxHttpRequest, invoice_id): { "client_name": request.POST.get("to_name"), "client_company": request.POST.get("to_company"), + "client_email": request.POST.get("to_email"), "client_address": request.POST.get("to_address"), "client_city": request.POST.get("to_city"), "client_county": request.POST.get("to_county"), diff --git a/backend/views/core/invoices/single/manage_access.py b/backend/views/core/invoices/single/manage_access.py index c8062b79..c62000a7 100644 --- a/backend/views/core/invoices/single/manage_access.py +++ b/backend/views/core/invoices/single/manage_access.py @@ -4,57 +4,48 @@ from backend.decorators import web_require_scopes from backend.models import Invoice, InvoiceURL, QuotaLimit +from backend.service.invoices.single.get_invoice import get_invoice_by_actor from backend.types.htmx import HtmxHttpRequest +from backend.types.requests import WebRequest @web_require_scopes("invoices:write", False, False, "invoices:single:dashboard") -def manage_access(request: HtmxHttpRequest, invoice_id): - try: - invoice = Invoice.objects.prefetch_related("invoice_urls").get(id=invoice_id, user=request.user) - except Invoice.DoesNotExist: +def manage_access(request: WebRequest, invoice_id): + invoice_resp = get_invoice_by_actor(request.actor, invoice_id, ["invoice_urls"]) + if invoice_resp.failed: messages.error(request, "Invoice not found") return redirect("invoices:single:dashboard") - all_access_codes = invoice.invoice_urls.values_list("uuid", "created_on").order_by("-created_on") + all_access_codes = invoice_resp.response.invoice_urls.values_list("uuid", "created_on").order_by("-created_on") return render( request, "pages/invoices/single/manage_access/manage_access.html", - {"all_codes": all_access_codes, "invoice": invoice}, + {"all_codes": all_access_codes, "invoice": invoice_resp.response}, ) @web_require_scopes("invoices:write", False, False, "invoices:single:dashboard") -def create_code(request: HtmxHttpRequest, invoice_id): +def create_code(request: WebRequest, invoice_id): if not request.htmx: return redirect("invoices:single:dashboard") if request.method != "POST": return HttpResponse("Invalid request", status=400) - try: - invoice = Invoice.objects.get(id=invoice_id, user=request.user) - except Invoice.DoesNotExist: - return HttpResponse("Invoice not found", status=400) - - limit = QuotaLimit.objects.get(slug="invoices-access_codes").get_quota_limit(user=request.user) - - current_amount = InvoiceURL.objects.filter(invoice_id=invoice_id).count() - - if current_amount >= limit: - messages.error(request, f"You have reached the quota limit for this service 'access_codes'") - return render(request, "partials/messages_list.html", {"autohide": False}) + invoice_resp = get_invoice_by_actor(request.actor, invoice_id, ["invoice_urls"]) + if invoice_resp.failed: + messages.error(request, "Invoice not found") + return redirect("invoices:single:dashboard") - code = InvoiceURL.objects.create(invoice=invoice, created_by=request.user) + code = InvoiceURL.objects.create(invoice=invoice_resp.response, created_by=request.user) messages.success(request, "Successfully created code") - # QuotaUsage.create_str(request.user, "invoices-access_codes", invoice_id) - return render( request, "pages/invoices/single/manage_access/_table_row.html", - {"code": code.uuid, "created_on": code.created_on, "added": True}, + {"code": code.uuid, "created_on": code.created_on, "created_by": code.get_created_by, "added": True}, ) @@ -68,6 +59,10 @@ def delete_code(request: HtmxHttpRequest, code): invoice = Invoice.objects.get(id=code_obj.invoice.id) if not invoice.has_access(request.user): raise Invoice.DoesNotExist + + # url was created by system | user cannot delete + if not code_obj.created_by: + raise InvoiceURL.DoesNotExist except (Invoice.DoesNotExist, InvoiceURL.DoesNotExist): messages.error(request, "Invalid URL") return render(request, "base/toasts.html") diff --git a/backend/views/core/settings/view.py b/backend/views/core/settings/view.py index 2807c112..340bb763 100644 --- a/backend/views/core/settings/view.py +++ b/backend/views/core/settings/view.py @@ -5,7 +5,15 @@ from django.shortcuts import render from backend.service.defaults.get import get_account_defaults -from backend.service.settings.view import validate_page, get_user_profile, get_api_keys +from backend.service.settings.view import ( + validate_page, + get_user_profile, + get_api_keys, + account_page_context, + api_keys_page_context, + account_defaults_context, + email_templates_context, +) from backend.types.requests import WebRequest @@ -21,13 +29,13 @@ def view_settings_page_endpoint(request: WebRequest, page: str | None = None): match page: case "account": - user_profile = get_user_profile(request) - context.update({"currency_signs": user_profile.CURRENCIES, "currency": user_profile.currency}) + account_page_context(request, context) case "api_keys": - api_keys = get_api_keys(request) - context.update({"api_keys": api_keys}) + api_keys_page_context(request, context) case "account_defaults": - context.update({"account_defaults": get_account_defaults(request.actor)}) + account_defaults_context(request, context) + case "email_templates": + email_templates_context(request, context) template = f"pages/settings/pages/{page or 'profile'}.html" diff --git a/backend/webhooks/invoices/recurring.py b/backend/webhooks/invoices/recurring.py index 4251e13e..5a58550b 100644 --- a/backend/webhooks/invoices/recurring.py +++ b/backend/webhooks/invoices/recurring.py @@ -8,7 +8,7 @@ from backend.decorators import feature_flag_check from backend.models import InvoiceRecurringProfile, Invoice, DefaultValues, AuditLog from backend.service.defaults.get import get_account_defaults -from backend.service.invoices.recurring.generation.next_invoice import generate_next_invoice_service +from backend.service.invoices.recurring.generation.next_invoice import safe_generate_next_invoice_service from backend.service.invoices.recurring.webhooks.webhook_apikey_auth import authenticate_api_key import logging @@ -49,7 +49,7 @@ def handle_recurring_invoice_webhook_endpoint(request: WebRequest): DATE_TODAY = datetime.now().date() - svc_resp = generate_next_invoice_service(invoice_recurring_profile=invoice_recurring_profile, issue_date=DATE_TODAY) + svc_resp = safe_generate_next_invoice_service(invoice_recurring_profile=invoice_recurring_profile, issue_date=DATE_TODAY) if svc_resp.success: logger.info("Successfully generated next invoice") diff --git a/docs/user-guide/emails/templates/index.md b/docs/user-guide/emails/templates/index.md new file mode 100644 index 00000000..95fad705 --- /dev/null +++ b/docs/user-guide/emails/templates/index.md @@ -0,0 +1,42 @@ +# Email Templates + +### Common Variables + +| Variable | Usage | +|------------------|-----------------------------------------------------------------------------------------| +| $first_name | Displays the users first name | +| $invoice_id | Displays the unique invoice ID | +| $invoice_ref | Displays the invoice reference ID you may have attached | +| $due_date | Will display the date that the invoice is due (e.g. 12th December 2024) | +| $amount_due | Will display the balance due for the invoice | +| $currency | Will display the currency TEXT used for the invoice (e.g. USD) | +| $currency_symbol | Will display the currency SYMBOL used for the invoice (e.g. $) | +| $product_list | Will display a bullet point list of all product (names no descriptions) | +| $company_name | Will display the company (or user) name of the sender | +| $invoice_link | Will provide a link that allows the user to view their invoice always up to date online | + +### Examples + +``` +Hi $first_name, + +The invoice $invoice_id has been created for you to pay, due on the $due_date. Please pay at your earliest convenience. + +Balance Due: $amount_due $currency + +Many thanks, +$company_name +``` + +may display + +``` +Hi John, + +The invoice 0054 has been created for you to pay, due on the 13th of October. Please pay at your earliest convenience. + +Balance Due: 150 USD + +Many thanks, +Strelix +``` diff --git a/frontend/templates/base/topbar/+icon_dropdown.html b/frontend/templates/base/topbar/+icon_dropdown.html index 0e3bc67f..a39f5765 100644 --- a/frontend/templates/base/topbar/+icon_dropdown.html +++ b/frontend/templates/base/topbar/+icon_dropdown.html @@ -5,7 +5,7 @@ - Account Settings + Settings
  • diff --git a/frontend/templates/modals/invoices_to_destination.html b/frontend/templates/modals/invoices_to_destination.html index 3ea7bb72..ebe1c8b0 100644 --- a/frontend/templates/modals/invoices_to_destination.html +++ b/frontend/templates/modals/invoices_to_destination.html @@ -49,6 +49,14 @@ value="{{ to_company }}" class="input input-bordered max-w-full"> +
    + + +
    {% if email_list %}{% endif %} {% for email in email_list %} - + {% empty %} {% endfor %} @@ -41,6 +42,20 @@ Please enter a valid subject between 8 and 64 characters.
    +
    +
    + + +
    +
    + + +
    +
    diff --git a/frontend/templates/modals/send_single_email.html b/frontend/templates/modals/send_single_email.html index 183f8c54..417ffd68 100644 --- a/frontend/templates/modals/send_single_email.html +++ b/frontend/templates/modals/send_single_email.html @@ -17,7 +17,7 @@ required> {% if email_list %}{% endif %} {% for email in email_list %} - + {% empty %} {% endfor %} diff --git a/frontend/templates/pages/invoices/create/destinations/_to_destination.html b/frontend/templates/pages/invoices/create/destinations/_to_destination.html index b05acbf2..528f7fda 100644 --- a/frontend/templates/pages/invoices/create/destinations/_to_destination.html +++ b/frontend/templates/pages/invoices/create/destinations/_to_destination.html @@ -27,6 +27,7 @@

    To

    {% else %}

    {{ to_name | default:"No Name" }}

    {{ to_company | default:"No Company" }}

    +

    {{ to_email | default:"No Email Associated" }}

    {{ to_address | default:"No address" }}

    {{ to_city | default:"No city" }}

    {{ to_county | default:"No county" }}

    @@ -43,6 +44,7 @@

    To

    + diff --git a/frontend/templates/pages/invoices/dashboard/_fetch_body.html b/frontend/templates/pages/invoices/dashboard/_fetch_body.html index f2c78ac2..8754be4d 100644 --- a/frontend/templates/pages/invoices/dashboard/_fetch_body.html +++ b/frontend/templates/pages/invoices/dashboard/_fetch_body.html @@ -57,19 +57,22 @@
  • Preview
  • - + Manage Access
  • - + Edit @@ -162,55 +165,56 @@
  • - - - - {% empty %} - No Invoices Found - {% endfor %} - - {% for option in all_sort_options %} -
    - {% if sort == option or sort == "-"|add:option %}{% endif %} - {% if option == "payment_status" %} - Payment Status - {% elif option == "date_due" %} - Date - {% elif option == "amount" %} - Amount - {% else %} - ID - {% endif %} -
    + + + + + {% empty %} + No Invoices Found {% endfor %} - {% for filter_type, inner_filters in all_filters.items %} - {% for filter in inner_filters %} -
    - {% if filter in selected_filters %}{% endif %} - {{ filter | title }} -
    - {% endfor %} - {% endfor %} -
    - - - - - - -
    -
    - + + {% for option in all_sort_options %} +
    + {% if sort == option or sort == "-"|add:option %}{% endif %} + {% if option == "payment_status" %} + Payment Status + {% elif option == "date_due" %} + Date + {% elif option == "amount" %} + Amount + {% else %} + ID + {% endif %}
    + {% endfor %} + {% for filter_type, inner_filters in all_filters.items %} + {% for filter in inner_filters %} +
    + {% if filter in selected_filters %}{% endif %} + {{ filter | title }} +
    + {% endfor %} + {% endfor %} +
    + + + + + + +
    +
    + +
    diff --git a/frontend/templates/pages/invoices/recurring/dashboard/manage.html b/frontend/templates/pages/invoices/recurring/dashboard/manage.html index aa2fe82e..18e946d2 100644 --- a/frontend/templates/pages/invoices/recurring/dashboard/manage.html +++ b/frontend/templates/pages/invoices/recurring/dashboard/manage.html @@ -10,7 +10,7 @@ class="btn btn-sm btn-outline btn-secondary me-3 float-left">Back to list

    Invoice Recurring Profile (#{{ invoiceProfile.id }})

    -
    +
    + + + diff --git a/frontend/templates/pages/invoices/single/manage_access/manage_access.html b/frontend/templates/pages/invoices/single/manage_access/manage_access.html index e7a948aa..44399e2e 100644 --- a/frontend/templates/pages/invoices/single/manage_access/manage_access.html +++ b/frontend/templates/pages/invoices/single/manage_access/manage_access.html @@ -23,12 +23,13 @@

    Manage Access to Invoice #{{ invoice.id }}

    Code Date Created + Created By Actions {% for url in invoice.invoice_urls.all %} - {% with code=url.uuid created_on=url.created_on %} + {% with code=url.uuid created_on=url.created_on created_by=url.get_created_by %} {% include 'pages/invoices/single/manage_access/_table_row.html' %} {% endwith %} {% endfor %} diff --git a/frontend/templates/pages/settings/main.html b/frontend/templates/pages/settings/main.html index 37df00a0..1d5f7809 100644 --- a/frontend/templates/pages/settings/main.html +++ b/frontend/templates/pages/settings/main.html @@ -9,21 +9,30 @@ hx-vals='{"on_main": "True"}'>
  • + hx-replace-url="{% url 'settings:dashboard with page' page='profile' %}"> Public Profile
  • + hx-replace-url="{% url 'settings:dashboard with page' page='account' %}"> Account
  • +
    +

    Invoice Preferences

    +
  • + + + Email Templates + +
  • + hx-replace-url="{% url 'settings:dashboard with page' page='account_defaults' %}"> Account defaults @@ -32,7 +41,7 @@

    Access

  • + hx-replace-url="{% url 'settings:dashboard with page' page='api_keys' %}"> API Keys diff --git a/frontend/templates/pages/settings/pages/email_templates.html b/frontend/templates/pages/settings/pages/email_templates.html new file mode 100644 index 00000000..db288655 --- /dev/null +++ b/frontend/templates/pages/settings/pages/email_templates.html @@ -0,0 +1,22 @@ +
    +
    +
    +
    + + Email Templates + NEW +
    +
    + View Docs +
    + +
    +
    +
    +
    +
    {% include 'pages/settings/settings/email_templates/tabs.html' %}
    +
    +
    diff --git a/frontend/templates/pages/settings/settings/email_templates/tabs.html b/frontend/templates/pages/settings/settings/email_templates/tabs.html new file mode 100644 index 00000000..1259e0a3 --- /dev/null +++ b/frontend/templates/pages/settings/settings/email_templates/tabs.html @@ -0,0 +1,60 @@ +{% load strfilters %} +{% load dictfilters %} +
    + +
    +
    + + {% for template in 'invoice_created,invoice_overdue,invoice_cancelled'|split:"," %} +
    +

    {{ template|split:"_"|join:" "|title }}

    + {% spaceless %} + + {% endspaceless %} + +
    + {% endfor %} +
    +
    + +
    + +
    +
    diff --git a/mkdocs.yml b/mkdocs.yml index 382247e5..67d9fc82 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -114,6 +114,8 @@ nav: - User Guide: - user-guide/index.md - Pricing: user-guide/pricing/index.md + - Email Templates: + - Home: user-guide/emails/templates/index.md - Changelog: - changelog/index.md diff --git a/settings/helpers.py b/settings/helpers.py index 19459d7b..cd0069fc 100644 --- a/settings/helpers.py +++ b/settings/helpers.py @@ -111,7 +111,19 @@ def send_email( if get_var("DEBUG", "").lower() == "true": print(data) - return SingleEmailSendServiceResponse(True, response=None) + return SingleEmailSendServiceResponse( + True, + response=SendEmailResponseTypeDef( + MessageId="", + ResponseMetadata={ + "RequestId": "", + "HTTPStatusCode": 200, + "HTTPHeaders": {}, + "RetryAttempts": 0, + "HostId": "", + }, + ), + ) if EMAIL_SERVICE == "SES": if not isinstance(data.destination, list): @@ -164,6 +176,46 @@ def send_email( return SingleEmailSendServiceResponse(error_message="No email service configured") +def send_bulk_email( + email_list: list[BulkEmailEmailItem], + ConfigurationSetName: str | None = None, + from_address: str | None = None, +) -> BulkEmailSendServiceResponse: + + entries: list[BulkEmailEntryTypeDef] = [ + { + "Destination": { + "ToAddresses": [entry.destination] if not isinstance(entry.destination, list) else entry.destination, + "CcAddresses": entry.cc, + "BccAddresses": entry.bcc, + } + } + for entry in email_list + ] + + try: + response: SendBulkEmailResponseTypeDef = EMAIL_CLIENT.send_bulk_email( + FromEmailAddress=from_address or AWS_SES_FROM_ADDRESS, + BulkEmailEntries=entries, + ConfigurationSetName=ConfigurationSetName or "", + DefaultContent={}, + ) + + return BulkEmailSendServiceResponse(True, response=response) + except EMAIL_CLIENT.exceptions.MessageRejected: + return BulkEmailSendServiceResponse(error_message="Email rejected", response=locals().get("response", None)) + + except EMAIL_CLIENT.exceptions.AccountSuspendedException: + return BulkEmailSendServiceResponse(error_message="Email account suspended", response=locals().get("response", None)) + + except EMAIL_CLIENT.exceptions.SendingPausedException: + return BulkEmailSendServiceResponse(error_message="Email sending paused", response=locals().get("response", None)) + + except Exception as error: + exception(f"Unexpected error occurred: {error}") + return BulkEmailSendServiceResponse(error_message="Email service error", response=locals().get("response", None)) + + def send_templated_bulk_email( email_list: list[BulkEmailEmailItem], template_name: str, @@ -191,7 +243,7 @@ def send_templated_bulk_email( entries.append( { - "Destination": {"ToAddresses": destination}, + "Destination": {"ToAddresses": destination, "CcAddresses": entry.cc, "BccAddresses": entry.bcc}, "ReplacementEmailContent": {"ReplacementTemplate": {"ReplacementTemplateData": data_str}}, } ) diff --git a/tests/views/test_invoices.py b/tests/views/test_invoices.py index 24bdac59..aae1bf2d 100644 --- a/tests/views/test_invoices.py +++ b/tests/views/test_invoices.py @@ -49,6 +49,7 @@ def setUp(self): "date_issued": "2021-12-01", "to_name": "Client Name", "to_company": "Client Company", + "to_email": "Client Email", "to_address": "Client Address", "to_city": "Client City", "to_county": "Client County",