Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added apps/custom_email/__init__.py
Empty file.
6 changes: 6 additions & 0 deletions apps/custom_email/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class CustomEmailConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.custom_email"
7 changes: 7 additions & 0 deletions apps/custom_email/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.db import models


class EmailTypes(models.TextChoices):
INVITATION_TO_SPACE = "invitation_to_space"
VERIFICATION_CODE = "verification_code"
RESET_PASSWORD = "reset_password" # nosec B105
72 changes: 72 additions & 0 deletions apps/custom_email/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Generated by Django 5.0.6 on 2026-06-01 00:00

import django.db.models.deletion
import uuid
from django.db import migrations, models


class Migration(migrations.Migration):
initial = True

dependencies = [
("organization", "0004_alter_organization_template"),
]

operations = [
migrations.CreateModel(
name="OrganizationEmail",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"email_type",
models.CharField(
choices=[
("invitation_to_space", "Invitation To Space"),
("verification_code", "Verification Code"),
("reset_password", "Reset Password"),
],
max_length=255,
),
),
("sender_name", models.CharField(blank=True, max_length=255)),
("sender_email", models.CharField(blank=True, max_length=255)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To match the model definition change of sender_email to EmailField, update the field type in the initial migration as well.

Suggested change
("sender_email", models.CharField(blank=True, max_length=255)),
("sender_email", models.EmailField(blank=True, max_length=255)),

("theme_colors", models.JSONField(blank=True, default=dict)),
("footer_text", models.TextField(blank=True)),
(
"header_image",
models.CharField(blank=True, default="", max_length=500),
),
("show_logo", models.BooleanField(default=True)),
("social_links", models.JSONField(blank=True, default=dict)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"organization",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="organization_custom_emails",
to="organization.organization",
),
),
],
options={
"db_table": "custom_emails",
"constraints": [
models.UniqueConstraint(
fields=("organization", "email_type"),
name="unique_organization_email_type",
)
],
},
),
]
43 changes: 43 additions & 0 deletions apps/custom_email/migrations/0002_backfill_organization_emails.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from django.db import migrations

from apps.custom_email.service import get_default_organization_emails


def backfill_organization_emails(apps, schema_editor):
Organization = apps.get_model("organization", "Organization")
OrganizationEmail = apps.get_model("custom_email", "OrganizationEmail")

existing_pairs = set(
OrganizationEmail.objects.values_list("organization_id", "email_type")
)
emails_to_create = []
default_emails = get_default_organization_emails()

for organization in Organization.objects.all().iterator():
for email_data in default_emails:
email_type = email_data["email_type"]
if (organization.id, email_type) in existing_pairs:
continue

emails_to_create.append(
OrganizationEmail(
organization_id=organization.id,
**email_data,
)
)

if emails_to_create:
OrganizationEmail.objects.bulk_create(emails_to_create, batch_size=100)
Comment on lines +3 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Importing helper functions or models from application code (like apps.custom_email.service) inside Django migrations is a known anti-pattern. If the models or services change in the future, running this migration from scratch will fail because the historical database schema won't match the current codebase. Instead, define the default email data directly within the migration file or in a static constants file that does not import any Django models.

def get_default_emails():
    common_data = {
        "sender_name": "The @SpaceDF team",
        "sender_email": "support@spacedf.com",
        "footer_text": "©2025 Digital Fortress. All rights reserved.",
        "header_image": "",
        "show_logo": True,
        "social_links": {
            "linkedin_url": "https://vn.linkedin.com/company/digital-fortress-vn",
            "facebook_url": "https://www.facebook.com/digitalfortress.dev/",
            "tiktok_url": "",
            "instagram_url": "https://www.instagram.com/df.iot/",
        },
        "metadata": {
            "show_facebook": True,
            "show_instagram": True,
            "show_linkedin": True,
            "show_tiktok": False,
        },
    }
    return [
        {
            **common_data,
            "email_type": "invitation_to_space",
            "theme_colors": {
                "background_color": "#FFFFFF",
                "primary_color": "#171A28",
            },
        },
        {
            **common_data,
            "email_type": "verification_code",
            "theme_colors": {
                "background_color": "#FFFFFF",
                "primary_color": "#171A28",
            },
        },
        {
            **common_data,
            "email_type": "reset_password",
            "theme_colors": {
                "background_color": "#FFFFFF",
                "primary_color": "#171A28",
            },
        },
    ]


def backfill_organization_emails(apps, schema_editor):
    Organization = apps.get_model("organization", "Organization")
    OrganizationEmail = apps.get_model("custom_email", "OrganizationEmail")

    existing_pairs = set(
        OrganizationEmail.objects.values_list("organization_id", "email_type")
    )
    emails_to_create = []
    default_emails = get_default_emails()

    for organization in Organization.objects.all().iterator():
        for email_data in default_emails:
            email_type = email_data["email_type"]
            if (organization.id, email_type) in existing_pairs:
                continue

            emails_to_create.append(
                OrganizationEmail(
                    organization_id=organization.id,
                    **email_data,
                )
            )

    if emails_to_create:
        OrganizationEmail.objects.bulk_create(emails_to_create, batch_size=100)



class Migration(migrations.Migration):
dependencies = [
("custom_email", "0001_initial"),
]

operations = [
migrations.RunPython(
backfill_organization_emails,
migrations.RunPython.noop,
),
]
1 change: 1 addition & 0 deletions apps/custom_email/migrations/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

31 changes: 31 additions & 0 deletions apps/custom_email/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from common.models.base_model import BaseModel
from django.db import models

from apps.custom_email.constants import EmailTypes
from apps.organization.models import Organization


class OrganizationEmail(BaseModel):
organization = models.ForeignKey(
Organization,
on_delete=models.CASCADE,
related_name="organization_custom_emails",
)
email_type = models.CharField(max_length=255, choices=EmailTypes.choices)
sender_name = models.CharField(max_length=255, blank=True)
sender_email = models.CharField(max_length=255, blank=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using models.EmailField instead of models.CharField for email addresses is a standard Django best practice. It automatically validates that the input is a valid email address at both the model and form/serializer levels.

Suggested change
sender_email = models.CharField(max_length=255, blank=True)
sender_email = models.EmailField(max_length=255, blank=True)

theme_colors = models.JSONField(default=dict, blank=True)
footer_text = models.TextField(blank=True)
header_image = models.CharField(max_length=500, blank=True, default="")
show_logo = models.BooleanField(default=True)
social_links = models.JSONField(default=dict, blank=True)
metadata = models.JSONField(default=dict, blank=True)

class Meta:
db_table = "custom_emails"
constraints = [
models.UniqueConstraint(
fields=["organization", "email_type"],
name="unique_organization_email_type",
)
]
49 changes: 49 additions & 0 deletions apps/custom_email/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from common.apps.upload_file.service import get_presigned_url
from django.conf import settings
from rest_framework import serializers

from apps.custom_email.constants import EmailTypes
from apps.custom_email.models import OrganizationEmail


class OrganizationEmailSerializer(serializers.ModelSerializer):
class Meta:
model = OrganizationEmail
fields = [
"id",
"email_type",
"sender_name",
"sender_email",
"theme_colors",
"footer_text",
"header_image",
"show_logo",
"social_links",
"metadata",
]
extra_kwargs = {
"id": {"read_only": True},
"email_type": {"required": True},
"header_image": {"write_only": True},
}

def to_representation(self, instance):
data = super().to_representation(instance)
if instance.header_image:
data["url_header_image"] = get_presigned_url(
settings.AWS_S3.get("AWS_STORAGE_BUCKET_NAME"),
f"uploads/{instance.header_image}",
)
return data

image_mapping = {
EmailTypes.INVITATION_TO_SPACE: "auth/invitation.png",
EmailTypes.VERIFICATION_CODE: "auth/verification_code.png",
EmailTypes.RESET_PASSWORD: "auth/forget_password.png",
}
image_path = image_mapping.get(instance.email_type, "")
host = settings.HOST.rstrip("/")
data["url_header_image"] = (
f"{host}/static/images/{image_path}" if image_path else ""
)
return data
Comment on lines +32 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Accessing settings.AWS_S3 and settings.HOST directly can lead to AttributeError or other runtime crashes if these settings are not defined or are set to None in certain environments (e.g., local development or testing). Use getattr with a safe fallback to make this code more robust.

Suggested change
if instance.header_image:
data["url_header_image"] = get_presigned_url(
settings.AWS_S3.get("AWS_STORAGE_BUCKET_NAME"),
f"uploads/{instance.header_image}",
)
return data
image_mapping = {
EmailTypes.INVITATION_TO_SPACE: "auth/invitation.png",
EmailTypes.VERIFICATION_CODE: "auth/verification_code.png",
EmailTypes.RESET_PASSWORD: "auth/forget_password.png",
}
image_path = image_mapping.get(instance.email_type, "")
host = settings.HOST.rstrip("/")
data["url_header_image"] = (
f"{host}/static/images/{image_path}" if image_path else ""
)
return data
if instance.header_image:
aws_s3 = getattr(settings, "AWS_S3", {}) or {}
data["url_header_image"] = get_presigned_url(
aws_s3.get("AWS_STORAGE_BUCKET_NAME"),
f"uploads/{instance.header_image}",
)
return data
image_mapping = {
EmailTypes.INVITATION_TO_SPACE: "auth/invitation.png",
EmailTypes.VERIFICATION_CODE: "auth/verification_code.png",
EmailTypes.RESET_PASSWORD: "auth/forget_password.png",
}
image_path = image_mapping.get(instance.email_type, "")
host = getattr(settings, "HOST", "") or ""
host = host.rstrip("/")
data["url_header_image"] = (
f"{host}/static/images/{image_path}" if image_path and host else ""
)
return data

59 changes: 59 additions & 0 deletions apps/custom_email/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from apps.custom_email.constants import EmailTypes
from apps.custom_email.models import OrganizationEmail


def get_default_organization_emails():
common_data = {
"sender_name": "The @SpaceDF team",
"sender_email": "support@spacedf.com",
"footer_text": "©2025 Digital Fortress. All rights reserved.",
"header_image": "",
"show_logo": True,
"social_links": {
"linkedin_url": "https://vn.linkedin.com/company/digital-fortress-vn",
"facebook_url": "https://www.facebook.com/digitalfortress.dev/",
"tiktok_url": "",
"instagram_url": "https://www.instagram.com/df.iot/",
},
"metadata": {
"show_facebook": True,
"show_instagram": True,
"show_linkedin": True,
"show_tiktok": False,
},
}
return [
{
**common_data,
"email_type": EmailTypes.INVITATION_TO_SPACE,
"theme_colors": {
"background_color": "#FFFFFF",
"primary_color": "#171A28",
},
},
{
**common_data,
"email_type": EmailTypes.VERIFICATION_CODE,
"theme_colors": {
"background_color": "#FFFFFF",
"primary_color": "#171A28",
},
},
{
**common_data,
"email_type": EmailTypes.RESET_PASSWORD,
"theme_colors": {
"background_color": "#FFFFFF",
"primary_color": "#171A28",
},
},
]


def create_default_organization_email(organization):
return OrganizationEmail.objects.bulk_create(
[
OrganizationEmail(organization=organization, **email_data)
for email_data in get_default_organization_emails()
]
)
9 changes: 9 additions & 0 deletions apps/custom_email/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.urls import path

from apps.custom_email.views import ListCustomEmailView

app_name = "custom_email"

urlpatterns = [
path("custom-emails", ListCustomEmailView.as_view(), name="custom-emails-list"),
]
16 changes: 16 additions & 0 deletions apps/custom_email/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from common.pagination.base_pagination import BasePagination
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import OrderingFilter

from apps.custom_email.models import OrganizationEmail
from apps.custom_email.serializers import OrganizationEmailSerializer
from utils.views import OrganizationListAPIView


class ListCustomEmailView(OrganizationListAPIView):
serializer_class = OrganizationEmailSerializer
queryset = OrganizationEmail.objects.all()
organization_field = "organization"
pagination_class = BasePagination
filter_backends = [DjangoFilterBackend, OrderingFilter]
ordering = ["-created_at"]
7 changes: 6 additions & 1 deletion apps/organization/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ def get(self, request, slug_name):

setting = (
OrganizationSetting.objects.filter(organization=organization)
.prefetch_related("themes")
.select_related("organization")
.prefetch_related(
"themes",
"organization__organization_custom_emails",
"organization__organization_custom_page",
)
.first()
)

Expand Down
Loading
Loading