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
- 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
> 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 |
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
→ 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.

댓글 없음:
댓글 쓰기