Update calendar and email modules

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-12-15 23:16:08 -03:00
parent 9a309b4bea
commit 2f9622ab77
2 changed files with 298 additions and 18 deletions

View file

@ -249,10 +249,8 @@ pub async fn list_events(
Json(vec![])
}
/// List calendars
pub async fn list_calendars(
State(_state): State<Arc<AppState>>,
) -> Json<serde_json::Value> {
/// List calendars - JSON API for services
pub async fn list_calendars_api(State(_state): State<Arc<AppState>>) -> Json<serde_json::Value> {
Json(serde_json::json!({
"calendars": [
{
@ -265,16 +263,48 @@ pub async fn list_calendars(
}))
}
/// Get upcoming events
pub async fn upcoming_events(
State(_state): State<Arc<AppState>>,
) -> Json<serde_json::Value> {
/// List calendars - HTMX HTML response for UI
pub async fn list_calendars(State(_state): State<Arc<AppState>>) -> axum::response::Html<String> {
axum::response::Html(r#"
<div class="calendar-item" data-calendar-id="default">
<span class="calendar-checkbox checked" style="background: #3b82f6;" onclick="toggleCalendar(this)"></span>
<span class="calendar-name">My Calendar</span>
</div>
<div class="calendar-item" data-calendar-id="work">
<span class="calendar-checkbox checked" style="background: #22c55e;" onclick="toggleCalendar(this)"></span>
<span class="calendar-name">Work</span>
</div>
<div class="calendar-item" data-calendar-id="personal">
<span class="calendar-checkbox checked" style="background: #f59e0b;" onclick="toggleCalendar(this)"></span>
<span class="calendar-name">Personal</span>
</div>
"#.to_string())
}
/// Get upcoming events - JSON API for services
pub async fn upcoming_events_api(State(_state): State<Arc<AppState>>) -> Json<serde_json::Value> {
Json(serde_json::json!({
"events": [],
"message": "No upcoming events"
}))
}
/// Get upcoming events - HTMX HTML response for UI
pub async fn upcoming_events(State(_state): State<Arc<AppState>>) -> axum::response::Html<String> {
axum::response::Html(
r#"
<div class="upcoming-event">
<div class="upcoming-color" style="background: #3b82f6;"></div>
<div class="upcoming-info">
<span class="upcoming-title">No upcoming events</span>
<span class="upcoming-time">Create your first event</span>
</div>
</div>
"#
.to_string(),
)
}
pub async fn get_event(
State(_state): State<Arc<AppState>>,
Path(_id): Path<Uuid>,
@ -323,7 +353,43 @@ pub async fn import_ical(
Ok(Json(serde_json::json!({ "imported": events.len() })))
}
pub fn router(state: Arc<AppState>) -> Router {
/// New event form (HTMX HTML response)
pub async fn new_event_form(State(_state): State<Arc<AppState>>) -> axum::response::Html<String> {
axum::response::Html(r#"
<div class="event-form-content">
<p>Create a new event using the form on the right panel.</p>
</div>
"#.to_string())
}
/// New calendar form (HTMX HTML response)
pub async fn new_calendar_form(State(_state): State<Arc<AppState>>) -> axum::response::Html<String> {
axum::response::Html(r#"
<form class="calendar-form" hx-post="/api/calendar/calendars" hx-swap="none">
<div class="form-group">
<label>Calendar Name</label>
<input type="text" name="name" placeholder="My Calendar" required />
</div>
<div class="form-group">
<label>Color</label>
<div class="color-options">
<label><input type="radio" name="color" value="#3b82f6" checked /><span class="color-dot" style="background:#3b82f6"></span></label>
<label><input type="radio" name="color" value="#22c55e" /><span class="color-dot" style="background:#22c55e"></span></label>
<label><input type="radio" name="color" value="#f59e0b" /><span class="color-dot" style="background:#f59e0b"></span></label>
<label><input type="radio" name="color" value="#ef4444" /><span class="color-dot" style="background:#ef4444"></span></label>
<label><input type="radio" name="color" value="#8b5cf6" /><span class="color-dot" style="background:#8b5cf6"></span></label>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn-secondary" onclick="this.closest('.modal').classList.add('hidden')">Cancel</button>
<button type="submit" class="btn-primary">Create Calendar</button>
</div>
</form>
"#.to_string())
}
/// Configure calendar API routes
pub fn configure_calendar_routes() -> Router<Arc<AppState>> {
Router::new()
.route(
ApiUrls::CALENDAR_EVENTS,
@ -335,10 +401,14 @@ pub fn router(state: Arc<AppState>) -> Router {
)
.route("/api/calendar/export.ics", get(export_ical))
.route("/api/calendar/import", post(import_ical))
// UI-compatible endpoints
.route("/api/calendar/list", get(list_calendars))
.route("/api/calendar/upcoming", get(upcoming_events))
.with_state(state)
// JSON API endpoints for services
.route("/api/calendar/calendars", get(list_calendars_api))
.route("/api/calendar/events/upcoming", get(upcoming_events_api))
// HTMX UI endpoints (return HTML fragments)
.route("/ui/calendar/list", get(list_calendars))
.route("/ui/calendar/upcoming", get(upcoming_events))
.route("/ui/calendar/event/new", get(new_event_form))
.route("/ui/calendar/new", get(new_calendar_form))
}
#[cfg(test)]

View file

@ -36,6 +36,7 @@ async fn extract_user_from_session(state: &Arc<AppState>) -> Result<Uuid, String
/// Configure email API routes
pub fn configure() -> Router<Arc<AppState>> {
Router::new()
// JSON API endpoints for services
.route(ApiUrls::EMAIL_ACCOUNTS, get(list_email_accounts))
.route(
&format!("{}/add", ApiUrls::EMAIL_ACCOUNTS),
@ -45,11 +46,9 @@ pub fn configure() -> Router<Arc<AppState>> {
ApiUrls::EMAIL_ACCOUNT_BY_ID.replace(":id", "{account_id}"),
axum::routing::delete(delete_email_account),
)
.route(ApiUrls::EMAIL_LIST, get(list_emails_htmx).post(list_emails))
.route(ApiUrls::EMAIL_LIST, post(list_emails))
.route(ApiUrls::EMAIL_SEND, post(send_email))
.route(ApiUrls::EMAIL_DRAFT, post(save_draft))
.route("/api/email/folders", get(list_folders_htmx))
.route("/api/email/compose", get(compose_email_htmx))
.route(
ApiUrls::EMAIL_FOLDERS.replace(":account_id", "{account_id}"),
get(list_folders),
@ -65,13 +64,24 @@ pub fn configure() -> Router<Arc<AppState>> {
.replace(":email", "{email}"),
post(track_click),
)
.route("/api/email/:id", get(get_email_content_htmx))
.route("/api/email/:id", delete(delete_email_htmx))
// Email read tracking endpoints
.route("/api/email/tracking/pixel/{tracking_id}", get(serve_tracking_pixel))
.route("/api/email/tracking/status/{tracking_id}", get(get_tracking_status))
.route("/api/email/tracking/list", get(list_sent_emails_tracking))
.route("/api/email/tracking/stats", get(get_tracking_stats))
// UI HTMX endpoints (return HTML fragments)
.route("/ui/email/accounts", get(list_email_accounts_htmx))
.route("/ui/email/list", get(list_emails_htmx))
.route("/ui/email/folders", get(list_folders_htmx))
.route("/ui/email/compose", get(compose_email_htmx))
.route("/ui/email/:id", get(get_email_content_htmx))
.route("/ui/email/:id/delete", delete(delete_email_htmx))
.route("/ui/email/labels", get(list_labels_htmx))
.route("/ui/email/templates", get(list_templates_htmx))
.route("/ui/email/signatures", get(list_signatures_htmx))
.route("/ui/email/rules", get(list_rules_htmx))
.route("/ui/email/search", get(search_emails_htmx))
.route("/ui/email/auto-responder", post(save_auto_responder))
}
// Export SaveDraftRequest for other modules
@ -383,6 +393,63 @@ pub async fn add_email_account(
}))
}
/// List email accounts - HTMX HTML response for UI
pub async fn list_email_accounts_htmx(
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
// Get user_id from session
let user_id = match extract_user_from_session(&state).await {
Ok(id) => id,
Err(_) => {
return axum::response::Html(r#"
<div class="account-item" onclick="document.getElementById('add-account-modal').showModal()">
<span>+ Add email account</span>
</div>
"#.to_string());
}
};
let conn = state.conn.clone();
let accounts = tokio::task::spawn_blocking(move || {
let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {}", e))?;
diesel::sql_query(
"SELECT id, email, display_name, is_primary FROM user_email_accounts WHERE user_id = $1 AND is_active = true ORDER BY is_primary DESC"
)
.bind::<diesel::sql_types::Uuid, _>(user_id)
.load::<(Uuid, String, Option<String>, bool)>(&mut db_conn)
.map_err(|e| format!("Query failed: {}", e))
})
.await
.ok()
.and_then(|r| r.ok())
.unwrap_or_default();
if accounts.is_empty() {
return axum::response::Html(r#"
<div class="account-item" onclick="document.getElementById('add-account-modal').showModal()">
<span>+ Add email account</span>
</div>
"#.to_string());
}
let mut html = String::new();
for (id, email, display_name, is_primary) in accounts {
let name = display_name.unwrap_or_else(|| email.clone());
let primary_badge = if is_primary { r#"<span class="badge">Primary</span>"# } else { "" };
html.push_str(&format!(
r#"<div class="account-item" data-account-id="{}">
<span>{}</span>
{}
</div>"#,
id, name, primary_badge
));
}
axum::response::Html(html)
}
/// List email accounts - JSON API for services
pub async fn list_email_accounts(
State(state): State<Arc<AppState>>,
) -> Result<Json<ApiResponse<Vec<EmailAccountResponse>>>, EmailError> {
@ -2066,3 +2133,146 @@ struct EmailAccountRow {
#[diesel(sql_type = diesel::sql_types::Integer)]
pub smtp_port: i32,
}
// ===== HTMX UI Endpoint Handlers =====
/// List email labels (HTMX HTML response)
pub async fn list_labels_htmx(
State(_state): State<Arc<AppState>>,
) -> impl IntoResponse {
// Return default labels as HTML for HTMX
axum::response::Html(r#"
<div class="label-item" style="--label-color: #ef4444;">
<span class="label-dot" style="background: #ef4444;"></span>
<span>Important</span>
</div>
<div class="label-item" style="--label-color: #3b82f6;">
<span class="label-dot" style="background: #3b82f6;"></span>
<span>Work</span>
</div>
<div class="label-item" style="--label-color: #22c55e;">
<span class="label-dot" style="background: #22c55e;"></span>
<span>Personal</span>
</div>
<div class="label-item" style="--label-color: #f59e0b;">
<span class="label-dot" style="background: #f59e0b;"></span>
<span>Finance</span>
</div>
"#.to_string())
}
/// List email templates (HTMX HTML response)
pub async fn list_templates_htmx(
State(_state): State<Arc<AppState>>,
) -> impl IntoResponse {
axum::response::Html(r#"
<div class="template-item" onclick="useTemplate('welcome')">
<h4>Welcome Email</h4>
<p>Standard welcome message for new contacts</p>
</div>
<div class="template-item" onclick="useTemplate('followup')">
<h4>Follow Up</h4>
<p>General follow-up template</p>
</div>
<div class="template-item" onclick="useTemplate('meeting')">
<h4>Meeting Request</h4>
<p>Request a meeting with scheduling options</p>
</div>
<p class="text-sm text-gray" style="margin-top: 1rem; text-align: center;">
Click a template to use it
</p>
"#.to_string())
}
/// List email signatures (HTMX HTML response)
pub async fn list_signatures_htmx(
State(_state): State<Arc<AppState>>,
) -> impl IntoResponse {
axum::response::Html(r#"
<div class="signature-item" onclick="useSignature('default')">
<h4>Default Signature</h4>
<p class="text-sm text-gray">Best regards,<br>Your Name</p>
</div>
<div class="signature-item" onclick="useSignature('formal')">
<h4>Formal Signature</h4>
<p class="text-sm text-gray">Sincerely,<br>Your Name<br>Title | Company</p>
</div>
<p class="text-sm text-gray" style="margin-top: 1rem; text-align: center;">
Click a signature to insert it
</p>
"#.to_string())
}
/// List email rules (HTMX HTML response)
pub async fn list_rules_htmx(
State(_state): State<Arc<AppState>>,
) -> impl IntoResponse {
axum::response::Html(r#"
<div class="rule-item">
<div class="rule-header">
<span class="rule-name">Auto-archive newsletters</span>
<label class="toggle-label">
<input type="checkbox" checked>
<span class="toggle-switch"></span>
</label>
</div>
<p class="text-sm text-gray">From: *@newsletter.* Archive</p>
</div>
<div class="rule-item">
<div class="rule-header">
<span class="rule-name">Label work emails</span>
<label class="toggle-label">
<input type="checkbox" checked>
<span class="toggle-switch"></span>
</label>
</div>
<p class="text-sm text-gray">From: *@company.com Label: Work</p>
</div>
<button class="btn-secondary" style="width: 100%; margin-top: 1rem;">
+ Add New Rule
</button>
"#.to_string())
}
/// Search emails (HTMX HTML response)
pub async fn search_emails_htmx(
State(state): State<Arc<AppState>>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
let query = params.get("q").map(|s| s.as_str()).unwrap_or("");
if query.is_empty() {
return axum::response::Html(r#"
<div class="empty-state">
<p>Enter a search term to find emails</p>
</div>
"#.to_string());
}
// For now, return a placeholder - in production this would search the database
axum::response::Html(format!(r#"
<div class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
<h3>Searching for "{}"</h3>
<p>No results found. Try different keywords.</p>
</div>
"#, query))
}
/// Save auto-responder settings
pub async fn save_auto_responder(
State(_state): State<Arc<AppState>>,
axum::Form(form): axum::Form<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
info!("Saving auto-responder settings: {:?}", form);
// In production, save to database
axum::response::Html(r#"
<div class="notification success">
Auto-responder settings saved successfully!
</div>
"#.to_string())
}