Full width home advertisement

Post Page Advertisement [Top]

Project 3: Mail

Table of Contents

> Pre-Implementation
- ERD
- Migration Issue
- Frontend Architecture (inbox.js)
- Backend API Design (views.py)

> Implementation
- Mailbox
- Send Mail
- View Email
- Archive & Unarchive
- Reply

> UI/UX Improvements
- Mailbox UI Redesign
- Read/Unread Emphasis
- Hover Interaction
- Email Detail View Improvements
- Action Buttons (Archive / Reply)
- Reply UX Enhancement
- Error Handling
- Empty State Handling

> Result

> What I Learned

Pre-Implementation

ERD

Core Entities

> User (Django Custom User)

id / username / email / password
- This project uses a custom user model by extending Django's AbstractUser.

> Email (Core Model)

id (PK) 
user (FK) → owner of the email (mailbox owner) 
sender (FK) → sender of the email 
recipients (M2M) → list of recipients 
subject (Text) 
body (Text) 
timestamp (Datetime) 
read (Boolean) 
archived (Boolean)

Relationship Structure

User  ----< Email (sender)
User  ----< Email (user, owner)
User >---< Email (recipients, ManyToMany)

> Each email is duplicated per recipient. Instead of storing a single email object shared across users, the system creates multiple rows. 
- One email per recipient (with user = recipient)
- One email for the sender (for "sent mailbox"
This design simplifies mailbox filtering and state management (read, archived) per user.


Migration Issue

Problem

While running the distribution code and accessing /register, I encountered OperationalError: no such table: mail_user. At the same time, running python manage.py makemigrations returned 'No changes detected'. 

Root Cause

The project defines a custom user model: class User(AbstractUser): pass. However, Django defaults to using auth.User unless explicitly configured. Because of this mismatch, Django ignored the custom User model, the mail app models were effectively excluded from migrations. And database tables were never created. 

Solution

1. Register Custom User Model.
- In settings.py, AUTH_USER_MODEL = 'mail.User'
2. Reset Database State
- Because migrations had already partially run, delete database file and migration files.
db.sqlite3, mail/migrations/*.py
3. Resolve Missing Migrations Folder
"The system cannot find the path specified"
- This happened because the migrations folder didn't exist yet. 
- Fix: python manage.py makemigrations mail
4. Apply Migrations 
python manage.py migrate -> python manage.py runserver

Frontend Architecture (inbox.js Analysis)

The frontend operates as a Single Page Application(SPA) controlled by inbox.js.

Entry Point

document.addEventListener('DOMContentLoaded', function() {
- This initializes the app when the page loads.

Event Binding

Each navigation button is mapped to a function
- Inbox -> load_mailbox('inbox') #Default view
- Sent -> load_mailbox('sent')
- Archived -> load_mailbox('archive')
- Compose -> compose_email

Core Functions

> compose_email()
Shows compose view / Hides mailbox view / Resets form fields
> load_mailbox(mailbox)
- Dynamically updates mailbox title: mailbox.charAt(0).toUpperCase +mailbox.slice(1)
- Fetches emails via API
- Renders email list dynamically

Backend API Design (views.py)

Page Rendering(HTML)

- index(request) / login_view(request) / logout_view(request) / register(request)
=> These return Django templates

API Layer (JSON Responses)

1. Send Email API
POST /emails

> Flow
1) Parse JSON: data = json.loads(request.body)
2) Extract recipients: emails = data.get("recipients").split(",")
3) Validate users: User.objects.get(email=email)
4) Create email objects: for user in users Email(user=user, sender=request.user,...)

> One request -> Multiple DB rows (one per recipient)
> Response: {"message": "Email sent successfully."}

2. Mailbox API
GET /emails/inbox
GET /emails/sent
GET /emails/archive

> Logic
inbox: 
Email.objects.filter( user=request.user, recipients=request.user, archived=False )
Sent: 
Email.objects.filter( user=request.user, sender=request.user )
Archive:
archived=True

3. Email Detail & Update API
GET  /emails/<id>
PUT  /emails/<id>

> GET: Returns full email details
> PUT: Updates read=True, archived=True
> Response: 204 No Content

Authentication & Security

@login_required: Restricts access to authenticated users
@csrf_exempt: Used because requests are made via fetch()

API Summary

Method    Endpoint    Description
POST       /emails       Send email
GET        /emails/        List emails
GET        /emails/        Email detail
PUT        /emails/        Update (read/archive)

Implementation

Mailbox

Design Strategy

Django (mailbox view) -> fetch(`/emails/${mailbox}`) -> response.json() -> emails (JS array) -> forEach loop -> create div elements -> append to DOM -> render UI

Core Logic

The frontend retrieves email data from the backend API using fetch(), then dynamically renders each email. 
  fetch(`/emails/${mailbox}`)
  .then(response => response.json())
  .then(emails => {
    console.log(emails);

    emails.forEach(email => {
      const div = document.createElement('div');
      div.innerHTML = `<strong>${email.sender}</strong>${email.subject}<span style="float:right">${email.timestamp}</span>`;

      //Read or Unread
      if (email.read) {
        div.style.backgroundColor = "#e6e6e6a8";
      } else{
        div.style.backgroundColor = "white";
      }
      document.querySelector('#emails-view').append(div);
    });
  });

Key UI Behaviour

- Each email is rendered as a separate <div>. 
- Emails are appended dynamically to #emails-view
- Styling: Unread(white)/Read(grey)

Initial Issue (No data in Inbox)

> When I first tested this
- Sent an email from user1->user2 
- Checked user1 inbox 
- Result: console.log(emails) -> empty / Network tab -> No meaningful response

> This indicated
- Emails were not being stored in the database.
- Which implies no successful POST /emails request was being made
At this point, I shifted focus to implementing the Send Mail feature first.

Send Mail

Problem to Solve

HTML forms trigger a full page reload on submission. In an SPA, this breaks the flows because Javascript execution stops and fetch() results cannot be handled. 

Prevent Default Behaviour & Sending Data via fetch()

  document.querySelector('#compose-form').onsubmit = function(event) {
    event.preventDefault();

    fetch('/emails', {
      method: 'POST',
      body: JSON.stringify({
        recipients: document.querySelector('#compose-recipients').value,
        subject: document.querySelector('#compose-subject').value,
        body: document.querySelector('#compose-body').value
      })
    })
    .then(response => response.json())
    .then(result => {
      console.log(result);
      load_mailbox('sent');
    });
  };
JSON.stringify :HTTP requests send strings only. So converts JS objects into JSON strings.
load_mailbox('sent');: This is not just UI switching. It triggers GET /emails/sent -> fetch() -> re-render mailbox

Data Flow

User Input(form) -> JS object -> JSON.stringify() -> POST /emails -> Django backend -> Save Database -> Return JSON response


Testing & Verification

> Server Logs
POST /emails HTTP/1.1 201 -> Email successfully created 
GET /emaisl/sent HTTP/1.1 200 -> Sent mailbox

> Console Output
Array(1)
{sender: "user1@test.com", subject: "...", ...} 
- This confirms data exist in DB, backend correctly returns JSON, fetch -> response.json() pipeline works.


View Email

Goal

- Retrieve a single email via: GET /emails/<id>
- Render full email details: sender / recipients / subject / timestamp / body
- Update read status via: PUT /emails/<id>

View Structure & State Management

I added a new container in inbox.html
<div id="email-view"></div>

> View Switching Rules
State        emails-view    compose-view    email-view
Mailbox        block                none                none
Compose      none                block                none
Detail           none                none                block
document.querySelector('#email-view').style.display = 'none';: Add this line in compose_email and load_mailbox.

Making Emails Clickable

In 'load_mailbox', I attached a click event to each email element.
div.addEventListener('click', () => view_email(email.id));
This connects each email in the list to the detail view. 

view_email (View Email Feature)

function view_email(id) {
  //Change view
  document.querySelector('#emails-view').style.display = 'none';
  document.querySelector('#compose-view').style.display = 'none';
  document.querySelector('#email-view').style.display = 'block';

  //Get data
  fetch(`/emails/${id}`)
  .then(response => response.json())
  .then(email => {
    //Render details
    document.querySelector('#email-view').innerHTML = `
    <p><strong>From:</strong> ${email.sender}</p>
    <p><strong>To:</strong> ${email.recipients.join(', ')}</p>
    <p><strong>Subject:</strong> ${email.subject}</p>
    <p><strong>Timestamp:</strong> ${email.timestamp}</p>
    <hr>
    <p>${email.body}</p>`;

    //Mark the email as read
    fetch(`/emails/${id}`, {
      method: 'PUT',
      body: JSON.stringify({
        read: true
      })
    });
  });
}

Key Concepts

> GET -> response.json()
- The server returns JSON (string format)
- response.json() converts it into a JavaScript object.

> Rendering with innerHTML
- Completely replaces the contents of #email-view
- Used to dynamically inject structured HTML

> recipients.join(', ')
- email.recipients is an array: ["user1@test.com", "user2@test.com"]
- join(', ') converts it into: "user1@test.com, user2@test.com"

> Updating Read State (PUT Request)
Backend Behaviour(Django): 
email.read = data["read"]
email.save()
- This updates the database so that the next time the mailbox is fetched, email.read===true, UI automatically renders it as read(grey background).

Data Flow Summary

User clicks email -> view_email(id) -> GET /emails/<id> -> response.json() -> Render detail view(innerHTML) -> PUT /emails/<id> (read=true) -> DB updated -> Next mailbox fetch reflects read state

Key Points

- Detail view is a combination of UI state control + API interaction.
- innerHTML fully replaces content.
- join() is essential for rendering array data cleanly.
- Read state is persisted in DB, not just UI state.
- SPA architecture requires explicit control over view visibility, data fetching and rendering lifecycle.

Archive & Unarchive

Managing Multiple Views

At this stage, the application has three views: emails-view, compose-view, email-view. All view-switching functions must explicitly control visibility. 
> Fix
Add document.querySelector('#email-view').style.display = 'none'; in compose_email() and load_mailbox().

Introducing Global State

The Archive/Unarchive behaviour depends on where the user came from: Inbox, Archive. This requires sharing state between functions. 
- Set let current_mailbox = null; to Global state. 
- Inside load_mailbox(), add current_mailbox = mailbox;
> Data Flow
1) load_mailbox('inbox') -> current_mailbox = 'inbox'
2) User clicks email -> view_email(id)
3) view_email() reads current_mailbox -> Decide which button to render.

view_email (Archive Feature)

...
    //Button type
    let button = '';
    if (current_mailbox === 'inbox') {
      button = `<button id="archive-btn">Archive</button>`;
    } else if (current_mailbox === 'archive') {
      button = `<button id="archive-btn">Unarchive</button>`;
    }
...
    //Connect an event
    if (current_mailbox === 'inbox' || current_mailbox === 'archive') {
      document.querySelector('#archive-btn').addEventListener('click', () => {
        fetch(`/emails/${id}`, {
          method: 'PUT',
          body: JSON.stringify({
            archived: current_mailbox === 'inbox'
          })
        })
        .then(() => load_mailbox('inbox'));
      });
    }
> archived: current_mailbox === 'inbox' 
- If current_mailbox === 'inbox' -> true
- If current_mailbox === 'archive' -> false
    Current Mailbox    Expression Result    Action
            inbox                true                archive
            archive              false              unarchive
This works because the expression evaluates to a boolean value directly. 

> Behaviour Flow
- User click email -> view_email(id) -> GET /emails/<id> -> Render detail view -> Button rendered based on current_mailbox
- User clicks Archive/Unarchive -> PUT /emails/<id> (archived=true/false) -> load_mailbox('inbox') -> Separate PUT request sets read = true

Reply

Design Goal 

The Reply feature should be available in all mailboxes, open the compose view when clicked, and automatically pre-fill recipient, subject, body that including original message. 


Button Composition

The Reply button must always be displayed. 
button += `<button id="reply-btn">Reply</button>`;

> Why += instead of =?
- button already contains Archive/Unarchive button. 
- Using += appends Reply button without overwriting existing buttons. 

> Result
button = "<button>Archive</button><button>Reply</button>"
- Both buttons are rendered together via ${button}


Reply Flow

User clicks Reply button -> compose_email() called -> Switch to compose view -> Pre-fill input fields


Pre-filling Form Data

- Recipient: Reply goes back to the original sender. 
- Sender: Prevents duplicate prefixes.
- Body: Adds clear separation between new reply content and original message. 

Data Flow Clarification

> Backend -> Frontend Flow
models.py (serialize()) -> views.py (JsonResponse) -> fetch() -> email (JavaScript object) 

> Frontend Injection
document.querySelector(...).value = ...
- Inserts backend data into HTML <input> fields.

> email.sender: data from server / #compose-recipients: DOM input element


UI/UX Improvement: Message Formatting

> Problem
Initially, reply messages appeared as a single block of text, making it hard to distinguish. 

> Solution
- Add visual separator
- Preserve Line Breaks: HTML igonres line breaks by default. white-space:pre-line ensures \n(actual line breaks in UI

Final Combined view_email Function

At this stage, view_email handles Detail rendering, Read state update, Archive/Unarchive, Reply pre-fill logic. 
function view_email(id) {
  //Change view
  document.querySelector('#emails-view').style.display = 'none';
  document.querySelector('#compose-view').style.display = 'none';
  document.querySelector('#email-view').style.display = 'block';

  //Get data
  fetch(`/emails/${id}`)
  .then(response => response.json())
  .then(email => {
    //Archive/Unarchive, Reply Button
    let button = '';
    if (current_mailbox === 'inbox') {
      button = `<button id="archive-btn">Archive</button>`;
    } else if (current_mailbox === 'archive') {
      button = `<button id="archive-btn">Unarchive</button>`;
    }
    button += `<button id="reply-btn">Reply</button>`;

    //Render details
    document.querySelector('#email-view').innerHTML = `
    <p><strong>From:</strong> ${email.sender}</p>
    <p><strong>To:</strong> ${email.recipients.join(', ')}</p>
    <p><strong>Subject:</strong> ${email.subject}</p>
    <p><strong>Timestamp:</strong> ${email.timestamp}</p>
    <hr>
    <p style="white-space: pre-line;">${email.body}</p>
    ${button}`;

    //Archive event
    if (current_mailbox === 'inbox' || current_mailbox === 'archive') {
      document.querySelector('#archive-btn').addEventListener('click', () => {
        fetch(`/emails/${id}`, {
          method: 'PUT',
          body: JSON.stringify({
            archived: current_mailbox === 'inbox'
          })
        })
        .then(() => load_mailbox('inbox'));
      });
    }

    //Reply event
    document.querySelector('#reply-btn').addEventListener('click', () => {
      compose_email();
      document.querySelector('#compose-recipients').value = email.sender;

      let subject = email.subject;
      if (!subject.startsWith('Re:')) {
        subject = `Re: ${subject}`;
      }
      document.querySelector('#compose-subject').value = subject;
      document.querySelector('#compose-body').value =
      `(Reply here)
     
      --------------------------------------------
      On ${email.timestamp} ${email.sender} wrote:

      ${email.body}`;
    });

    //Mark as read
    fetch(`/emails/${id}`, {
      method: 'PUT',
      body: JSON.stringify({
        read: true
      })
    });
  });
}

UI/UX Improvements

Mailbox UI Redesign

Structured Layout

Instead of rendering emails as plain text, I redisigned each email as a card-like row: 
sender | subject            | timestamp
      div.innerHTML = `
      <div style="display:flex; justify-content:space-between;">
      <div style="display:flex; gap:15px;">
      <span style="min-width:150px;"><strong>${email.sender}</strong></span>
      <span>${email.subject}</span>
      </div>
      <span>${email.timestamp}</span>
      </div>`;

Card Styling

- Each email is visually separated. This makes each email behave like a clickable UI component, not just text. 
      const div = document.createElement('div');
      div.style.border = "1px solid #ccc";
      div.style.padding = "8px";
      div.style.margin = "5px 0";
      div.style.cursor = "pointer";
     

Read / Unread Emphasis

Visual Hierarchy

- Unread emails: Bold text, White background
- Read emails: Normal weight, Grey background
      if (email.read) {
        div.style.backgroundColor = "#e6e6e6a8";
      } else{
        div.style.backgroundColor = "white";
        div.style.fontWeight = "bold";
      }

Hover Interaction

      div.addEventListener('mouseover', () => {
        div.style.boxShadow = "0 2px 5px rgba(0,0,0,0.2)";
      });
     
      div.addEventListener('mouseout', () => {
        div.style.boxShadow = "none";
      });


Email Detail View Improvements

Spacing and Readability

- Added margin above email body
- Preserved line breaks using: <p style=" white-space: pre-line; margin-top:10px;">


Action Buttons (Archive / Reply)

> Layout and Positioning 
<div style="display:flex; justify-content:flex-end; gap:8px; margin-top:10px;">

> Button Styling (styles.css)

Reply UX Enhancement

Problem

Initial replies looked like a single continuous block, making it difficult to distinguish new message and original email. 

Solution: Quote Formatting

      document.querySelector('#compose-body').value =
      `\n\n-----------------------------------
On ${email.timestamp}, ${email.sender} wrote:

> ${email.body.replace(/\n/g, '\n> ')}`;
    });
     
- Quoting with > : Clearly separates original content
- Handling line breaks: \n -> line break, g -> apply globally, adds > to every new line.
- First line handling: Since .replace() doesn't affect the first line. > ${email.body} is handled manually before applying replacements. 
- Indentation Issue: Unexpected spacing before On {...}. Template literals preserve whitespace and indentation inside ` ` becomes part of the string. Align string to the left edge to solve this issue. 
- Problem: Nested Quote Prefix: Each reply adds > to every line. The original implementation does not check whether > already exists.
-> 
      const cleanedBody = email.body.replace(/^>+/gm, '');
      document.querySelector('#compose-body').value =
      `\n\n-----------------------------------
On ${email.timestamp}, ${email.sender} wrote:

> ${cleanedBody.replace(/\n/g, '\n> ')}`;
    });
    ^ : start of line
    >+: one or more > characters
    g: global
    m: multiline
    -> This removes all leading > characters from every line.

Error Handling

      if (result.error) {
        alert(result.error);
      } else {
        load_mailbox('sent');
- Alert when sending fails

Empty State Handling

    if (emails.length === 0) {
      document.querySelector('#emails-view').innerHTML += `
      <p style="color:gray; margin-top:10px;">No emails in this mailbox.</p>`;
      return;
    }
- Better UX than a blank screen.


Result

What I Learned

1. SPA Architecture with Django

I learned how Django can function not only as a template-based framework but also as a backend API provider in a Single Page Application. Instead of rendering new pages, the frontend dynamically updates the UI using fetch() and DOM manipulation.

2. Importance of Data Flow Understanding

Understanding the full pipeline was critical. 
Frontend (JS)
→ fetch()
→ Django views (API)
→ Database
→ JSON response
→ UI rendering
Debugging became much easier once I clearly understood how data flows through each layer. 


3. State-Driven UI Design

Using a global variable helped me implement context-aware UI behaviour.

4. Debugging Strategy

When something didn't work, I learned to systematically check console(FE data), network tab(API request/response), server logs(BE behaviour), database state. 

5. UXUI Matters

Presentation and usability are important. Small improvements such as hover effects, quote formatting in replies and handling empty states made the application much more complete.

6. Handling Edge Cases

I fixed subtle issues such as preventing duplicate Re: prefixes, normalizing > in reply quotes, handling empty mailboxex, displaying error messages. 


댓글 없음:

댓글 쓰기

Bottom Ad [Post Page]