Skip to main content

max / goingson

Split the three oversized SQLite repositories task_repo 1499 lines, contact_repo 1252 and email_repo 1209, each one trait impl with every query inlined. Each becomes a directory: the trait impl stays in mod.rs as one-line delegation and the bodies leave as pub(super) free functions taking &Connection. task_repo_state.rs folds in as task_repo/state.rs, which is where it belonged; it was the precedent this split follows. The delegation costs lines and that was accepted when the sheet was written: a trait impl cannot be split across modules, so the choice is delegation or nothing. Every public path survives, verified name by name against HEAD, and mod.rs declares the same modules at the same visibility. The repo now reports zero files over the module-size budget.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-04 15:02 UTC
Signed with PGP, not checked
Commit: bc55a5d7fab34133def12d74034ab7b8630eb675
Parent: e4eac68
26 files changed, +4856 insertions, -2503 deletions
@@ -35,7 +35,6 @@
35 35 mod subtask_repo;
36 36 mod sync_account_repo;
37 37 mod task_repo;
38 - mod task_repo_state;
39 38 pub(crate) mod time_session_repo;
40 39 mod user_repo;
41 40 mod weekly_review_repo;
@@ -11,7 +11,8 @@
11 11
12 12 use crate::utils::{execute, format_datetime, format_datetime_now, format_datetime_opt, query_all};
13 13
14 - use super::task_repo::{TASK_SELECT_COLUMNS, get_task_by_id, query_tasks};
14 + use super::fetch::{get_task_by_id, query_tasks};
15 + use super::row::TASK_SELECT_COLUMNS;
15 16
16 17 // Snooze
17 18
@@ -376,8 +377,8 @@
376 377 conn,
377 378 &sql,
378 379 params![user_id.to_string(), user_id.to_string(), limit],
379 - super::task_repo::TaskRowWithProject::from_row,
380 + super::row::TaskRowWithProject::from_row,
380 381 )?;
381 382
382 - super::task_repo::rows_to_tasks(conn, rows)
383 + super::fetch::rows_to_tasks(conn, rows)
383 384 }
@@ -1,1252 +1,0 @@
1 - //! SQLite implementation of the ContactRepository.
2 - //!
3 - //! Manages contacts with sub-collections (emails, phones, social handles).
4 - //! Sub-collections are stored in separate tables and batch-loaded for list operations.
5 -
6 - use goingson_core::{
7 - Contact, ContactCustomField, ContactEmail, ContactEmailEntry, ContactEmailId, ContactId,
8 - ContactPhone, ContactPhoneId, ContactRepository, CoreError, CustomFieldId, NewContact,
9 - NewContactCustomField, NewContactEmail, NewContactPhone, NewSocialHandle, Result, SocialHandle,
10 - SocialHandleId, UpdateContact, UserId,
11 - };
12 - use std::collections::{HashMap, HashSet};
13 -
14 - use crate::utils::{
15 - bind_placeholders, escape_like, execute, format_datetime, format_datetime_now, parse_datetime,
16 - parse_tags, parse_uuid, query_all, query_opt,
17 - };
18 - use rusqlite::{Connection, params, params_from_iter};
19 -
20 - use crate::Db;
21 -
22 - // Row Structs
23 -
24 - #[derive(Debug, Clone)]
25 - struct ContactRow {
26 - pub id: String,
27 - pub display_name: String,
28 - pub nickname: Option<String>,
29 - pub company: Option<String>,
30 - pub title: Option<String>,
31 - pub notes: String,
32 - pub tags: String,
33 - pub birthday: Option<String>,
34 - pub timezone: Option<String>,
35 - pub external_source: Option<String>,
36 - pub external_id: Option<String>,
37 - pub is_implicit: i32,
38 - pub created_at: String,
39 - pub updated_at: String,
40 - }
41 -
42 - impl ContactRow {
43 - fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
44 - Ok(Self {
45 - id: row.get("id")?,
46 - display_name: row.get("display_name")?,
47 - nickname: row.get("nickname")?,
48 - company: row.get("company")?,
49 - title: row.get("title")?,
50 - notes: row.get("notes")?,
51 - tags: row.get("tags")?,
52 - birthday: row.get("birthday")?,
53 - timezone: row.get("timezone")?,
54 - external_source: row.get("external_source")?,
55 - external_id: row.get("external_id")?,
56 - is_implicit: row.get("is_implicit")?,
57 - created_at: row.get("created_at")?,
58 - updated_at: row.get("updated_at")?,
59 - })
60 - }
61 - }
62 -
63 - #[derive(Debug, Clone)]
64 - struct ContactEmailRow {
65 - pub id: String,
66 - pub contact_id: String,
67 - pub address: String,
68 - pub label: String,
69 - pub is_primary: i32,
70 - }
71 -
72 - impl ContactEmailRow {
73 - fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
74 - Ok(Self {
75 - id: row.get("id")?,
76 - contact_id: row.get("contact_id")?,
77 - address: row.get("address")?,
78 - label: row.get("label")?,
79 - is_primary: row.get("is_primary")?,
80 - })
81 - }
82 - }
83 -
84 - #[derive(Debug, Clone)]
85 - struct ContactPhoneRow {
86 - pub id: String,
87 - pub contact_id: String,
88 - pub number: String,
89 - pub label: String,
90 - pub is_primary: i32,
91 - }
92 -
93 - impl ContactPhoneRow {
94 - fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
95 - Ok(Self {
96 - id: row.get("id")?,
97 - contact_id: row.get("contact_id")?,
98 - number: row.get("number")?,
99 - label: row.get("label")?,
100 - is_primary: row.get("is_primary")?,
101 - })
102 - }
103 - }
104 -
105 - #[derive(Debug, Clone)]
106 - struct SocialHandleRow {
107 - pub id: String,
108 - pub contact_id: String,
109 - pub platform: String,
110 - pub handle: String,
111 - pub url: Option<String>,
112 - }
113 -
114 - impl SocialHandleRow {
115 - fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
116 - Ok(Self {
117 - id: row.get("id")?,
118 - contact_id: row.get("contact_id")?,
119 - platform: row.get("platform")?,
120 - handle: row.get("handle")?,
121 - url: row.get("url")?,
122 - })
123 - }
124 - }
125 -
126 - #[derive(Debug, Clone)]
127 - struct CustomFieldRow {
128 - pub id: String,
129 - pub contact_id: String,
130 - pub label: String,
131 - pub value: String,
132 - pub url: Option<String>,
133 - }
134 -
135 - impl CustomFieldRow {
136 - fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
137 - Ok(Self {
138 - id: row.get("id")?,
139 - contact_id: row.get("contact_id")?,
140 - label: row.get("label")?,
141 - value: row.get("value")?,
142 - url: row.get("url")?,
143 - })
144 - }
145 - }
146 -
147 - // Row Conversions
148 -
149 - fn contact_email_from_row(row: ContactEmailRow) -> Result<ContactEmail> {
150 - Ok(ContactEmail {
151 - id: parse_uuid(&row.id)?.into(),
152 - contact_id: parse_uuid(&row.contact_id)?.into(),
153 - address: row.address,
154 - label: row.label,
155 - is_primary: row.is_primary != 0,
156 - })
157 - }
158 -
159 - fn contact_phone_from_row(row: ContactPhoneRow) -> Result<ContactPhone> {
160 - Ok(ContactPhone {
161 - id: parse_uuid(&row.id)?.into(),
162 - contact_id: parse_uuid(&row.contact_id)?.into(),
163 - number: row.number,
164 - label: row.label,
165 - is_primary: row.is_primary != 0,
166 - })
167 - }
168 -
169 - fn social_handle_from_row(row: SocialHandleRow) -> Result<SocialHandle> {
170 - Ok(SocialHandle {
171 - id: parse_uuid(&row.id)?.into(),
172 - contact_id: parse_uuid(&row.contact_id)?.into(),
173 - platform: row.platform,
174 - handle: row.handle,
175 - url: row.url,
176 - })
177 - }
178 -
179 - fn custom_field_from_row(row: CustomFieldRow) -> Result<ContactCustomField> {
180 - Ok(ContactCustomField {
181 - id: parse_uuid(&row.id)?.into(),
182 - contact_id: parse_uuid(&row.contact_id)?.into(),
183 - label: row.label,
184 - value: row.value,
185 - url: row.url,
186 - })
187 - }
188 -
189 - fn contact_from_row(
190 - row: ContactRow,
191 - emails: Vec<ContactEmail>,
192 - phones: Vec<ContactPhone>,
193 - social_handles: Vec<SocialHandle>,
194 - custom_fields: Vec<ContactCustomField>,
195 - ) -> Result<Contact> {
196 - let birthday = row
197 - .birthday
198 - .as_deref()
199 - .map(|s| {
200 - chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
201 - .map_err(|e| CoreError::database_msg(format!("Invalid birthday: {e}")))
202 - })
203 - .transpose()?;
204 -
205 - Ok(Contact {
206 - id: parse_uuid(&row.id)?.into(),
207 - display_name: row.display_name,
208 - nickname: row.nickname,
209 - company: row.company,
210 - title: row.title,
211 - notes: row.notes,
212 - tags: parse_tags(&row.tags),
213 - birthday,
214 - timezone: row.timezone,
215 - external_source: row.external_source,
216 - external_id: row.external_id,
217 - is_implicit: row.is_implicit != 0,
218 - emails,
219 - phones,
220 - social_handles,
221 - custom_fields,
222 - created_at: parse_datetime(&row.created_at)?,
223 - updated_at: parse_datetime(&row.updated_at)?,
224 - })
225 - }
226 -
227 - // Repository
228 -
229 - /// SQLite-backed implementation of [`ContactRepository`].
230 - pub struct SqliteContactRepository {
231 - db: Db,
232 - }
233 -
234 - impl SqliteContactRepository {
235 - /// Creates a new repository instance with the given connection pool.
236 - #[tracing::instrument(skip_all)]
237 - pub fn new(db: Db) -> Self {
238 - Self { db }
239 - }
240 -
241 - /// Batch-load emails for a set of contact IDs.
242 - fn load_emails_for_contacts(
243 - conn: &Connection,
244 - ids: &[String],
245 - ) -> Result<HashMap<ContactId, Vec<ContactEmail>>> {
246 - if ids.is_empty() {
247 - return Ok(HashMap::new());
248 - }
249 -
250 - let placeholders = bind_placeholders(ids.len());
251 - let sql = format!(
252 - "SELECT id, contact_id, address, label, is_primary FROM contact_emails WHERE contact_id IN ({placeholders}) ORDER BY is_primary DESC, rowid ASC"
253 - );
254 -
255 - let rows = query_all(
256 - conn,
257 - &sql,
258 - params_from_iter(ids.iter()),
259 - ContactEmailRow::from_row,
260 - )?;
261 - let mut map: HashMap<ContactId, Vec<ContactEmail>> = HashMap::new();
262 - for row in rows {
263 - let contact_id: ContactId = parse_uuid(&row.contact_id)?.into();
264 - let email = contact_email_from_row(row)?;
265 - map.entry(contact_id).or_default().push(email);
266 - }
267 - Ok(map)
268 - }
269 -
270 - /// Batch-load phones for a set of contact IDs.
271 - fn load_phones_for_contacts(
272 - conn: &Connection,
273 - ids: &[String],
274 - ) -> Result<HashMap<ContactId, Vec<ContactPhone>>> {
275 - if ids.is_empty() {
276 - return Ok(HashMap::new());
277 - }
278 -
279 - let placeholders = bind_placeholders(ids.len());
280 - let sql = format!(
281 - "SELECT id, contact_id, number, label, is_primary FROM contact_phones WHERE contact_id IN ({placeholders}) ORDER BY is_primary DESC, rowid ASC"
282 - );
283 -
284 - let rows = query_all(
285 - conn,
286 - &sql,
287 - params_from_iter(ids.iter()),
288 - ContactPhoneRow::from_row,
289 - )?;
290 - let mut map: HashMap<ContactId, Vec<ContactPhone>> = HashMap::new();
291 - for row in rows {
292 - let contact_id: ContactId = parse_uuid(&row.contact_id)?.into();
293 - let phone = contact_phone_from_row(row)?;
294 - map.entry(contact_id).or_default().push(phone);
295 - }
296 - Ok(map)
297 - }
298 -
299 - /// Batch-load social handles for a set of contact IDs.
300 - fn load_social_handles_for_contacts(
301 - conn: &Connection,
302 - ids: &[String],
303 - ) -> Result<HashMap<ContactId, Vec<SocialHandle>>> {
304 - if ids.is_empty() {
305 - return Ok(HashMap::new());
306 - }
307 -
308 - let placeholders = bind_placeholders(ids.len());
309 - let sql = format!(
310 - "SELECT id, contact_id, platform, handle, url FROM contact_social_handles WHERE contact_id IN ({placeholders}) ORDER BY rowid ASC"
311 - );
312 -
313 - let rows = query_all(
314 - conn,
315 - &sql,
316 - params_from_iter(ids.iter()),
317 - SocialHandleRow::from_row,
318 - )?;
319 - let mut map: HashMap<ContactId, Vec<SocialHandle>> = HashMap::new();
320 - for row in rows {
321 - let contact_id: ContactId = parse_uuid(&row.contact_id)?.into();
322 - let handle = social_handle_from_row(row)?;
323 - map.entry(contact_id).or_default().push(handle);
324 - }
325 - Ok(map)
326 - }
327 -
328 - /// Batch-load custom fields for a set of contact IDs.
329 - fn load_custom_fields_for_contacts(
330 - conn: &Connection,
331 - ids: &[String],
332 - ) -> Result<HashMap<ContactId, Vec<ContactCustomField>>> {
333 - if ids.is_empty() {
334 - return Ok(HashMap::new());
335 - }
336 -
337 - let placeholders = bind_placeholders(ids.len());
338 - let sql = format!(
339 - "SELECT id, contact_id, label, value, url FROM contact_custom_fields WHERE contact_id IN ({placeholders}) ORDER BY rowid ASC"
340 - );
341 -
342 - let rows = query_all(
343 - conn,
344 - &sql,
345 - params_from_iter(ids.iter()),
346 - CustomFieldRow::from_row,
347 - )?;
348 - let mut map: HashMap<ContactId, Vec<ContactCustomField>> = HashMap::new();
349 - for row in rows {
350 - let contact_id: ContactId = parse_uuid(&row.contact_id)?.into();
351 - let field = custom_field_from_row(row)?;
352 - map.entry(contact_id).or_default().push(field);
353 - }
354 - Ok(map)
355 - }
356 -
357 - /// Hydrate a list of contact rows with their sub-collections.
358 - fn hydrate_contacts(conn: &Connection, rows: Vec<ContactRow>) -> Result<Vec<Contact>> {
359 - if rows.is_empty() {
360 - return Ok(vec![]);
361 - }
362 -
363 - let ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
364 - let mut emails_map = Self::load_emails_for_contacts(conn, &ids)?;
365 - let mut phones_map = Self::load_phones_for_contacts(conn, &ids)?;
366 - let mut social_map = Self::load_social_handles_for_contacts(conn, &ids)?;
367 - let mut custom_map = Self::load_custom_fields_for_contacts(conn, &ids)?;
368 -
369 - let mut contacts = Vec::with_capacity(rows.len());
370 - for row in rows {
371 - let id: ContactId = parse_uuid(&row.id)?.into();
372 - let emails = emails_map.remove(&id).unwrap_or_default();
373 - let phones = phones_map.remove(&id).unwrap_or_default();
374 - let social_handles = social_map.remove(&id).unwrap_or_default();
375 - let custom_fields = custom_map.remove(&id).unwrap_or_default();
376 - contacts.push(contact_from_row(
377 - row,
378 - emails,
379 - phones,
380 - social_handles,
381 - custom_fields,
382 - )?);
383 - }
384 - Ok(contacts)
385 - }
386 - }
387 -
388 - impl ContactRepository for SqliteContactRepository {
389 - #[tracing::instrument(skip_all)]
390 - fn list_all(&self, user_id: UserId) -> Result<Vec<Contact>> {
391 - let conn = self.db.conn()?;
392 - let rows = query_all(
393 - &conn,
394 - r"
395 - SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at
396 - FROM contacts
397 - WHERE user_id = ? AND is_implicit = 0
398 - ORDER BY display_name ASC
399 - ",
400 - params![user_id.to_string()],
401 - ContactRow::from_row,
402 - )?;
403 -
404 - Self::hydrate_contacts(&conn, rows)
405 - }
406 -
407 - fn list_all_for_backup(&self, user_id: UserId) -> Result<Vec<Contact>> {
408 - let conn = self.db.conn()?;
409 - let rows = query_all(
410 - &conn,
411 - r"
412 - SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at
413 - FROM contacts
414 - WHERE user_id = ?
415 - ORDER BY display_name ASC
416 - ",
417 - params![user_id.to_string()],
418 - ContactRow::from_row,
419 - )?;
420 -
421 - Self::hydrate_contacts(&conn, rows)
422 - }
423 -
424 - #[tracing::instrument(skip_all)]
425 - fn get_by_id(&self, id: ContactId, user_id: UserId) -> Result<Option<Contact>> {
426 - let conn = self.db.conn()?;
427 - let row = query_opt(
428 - &conn,
429 - r"
430 - SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at
431 - FROM contacts
432 - WHERE id = ? AND user_id = ?
433 - ",
434 - params![id.to_string(), user_id.to_string()],
435 - ContactRow::from_row,
436 - )?;
437 -
438 - match row {
439 - Some(r) => {
440 - let contacts = Self::hydrate_contacts(&conn, vec![r])?;
441 - Ok(contacts.into_iter().next())
442 - }
443 - None => Ok(None),
444 - }
445 - }
446 -
447 - #[tracing::instrument(skip_all)]
448 - fn create(&self, user_id: UserId, contact: NewContact) -> Result<Contact> {
449 - let conn = self.db.conn()?;
450 - let id = ContactId::new();
451 - let now = format_datetime_now();
452 - let tags_json = serde_json::to_string(&contact.tags).unwrap_or_else(|_| "[]".to_string());
453 - let birthday_str = contact.birthday.map(|d| d.format("%Y-%m-%d").to_string());
454 -
455 - execute(
456 - &conn,
457 - r"
458 - INSERT INTO contacts (id, user_id, display_name, nickname, company, title, notes, tags, birthday, timezone, is_implicit, created_at, updated_at)
459 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
460 - ",
461 - params![
462 - id.to_string(),
463 - user_id.to_string(),
464 - &contact.display_name,
465 - &contact.nickname,
466 - &contact.company,
467 - &contact.title,
468 - &contact.notes,
469 - &tags_json,
470 - &birthday_str,
471 - &contact.timezone,
472 - i32::from(contact.is_implicit),
473 - &now,
474 - &now
475 - ],
476 - )?;
477 -
478 - drop(conn);
479 - self.get_by_id(id, user_id)?
480 - .ok_or_else(|| CoreError::internal("Failed to retrieve created contact"))
481 - }
482 -
483 - #[tracing::instrument(skip_all)]
484 - fn restore(&self, user_id: UserId, contact: &Contact) -> Result<()> {
485 - let conn = self.db.conn()?;
486 - let tags_json = serde_json::to_string(&contact.tags).unwrap_or_else(|_| "[]".to_string());
487 - let birthday_str = contact.birthday.map(|d| d.format("%Y-%m-%d").to_string());
488 -
489 - execute(
490 - &conn,
491 - r"
492 - INSERT OR IGNORE INTO contacts (id, user_id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at)
493 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
494 - ",
495 - params![
496 - contact.id.to_string(),
497 - user_id.to_string(),
498 - &contact.display_name,
499 - &contact.nickname,
500 - &contact.company,
Lines truncated
@@ -1,0 +1,188 @@
1 + //! Create, update and delete for the contact row itself.
2 + //!
3 + //! Sub-collection mutations live in `subcollections`; reads live in `query`.
4 +
5 + use goingson_core::{Contact, ContactId, CoreError, NewContact, Result, UpdateContact, UserId};
6 +
7 + use rusqlite::{Connection, params, params_from_iter};
8 +
9 + use crate::utils::{bind_placeholders, escape_like, execute, format_datetime, format_datetime_now};
10 +
11 + use super::query;
12 +
13 + pub(super) fn create(conn: &Connection, user_id: UserId, contact: &NewContact) -> Result<Contact> {
14 + let id = ContactId::new();
15 + let now = format_datetime_now();
16 + let tags_json = serde_json::to_string(&contact.tags).unwrap_or_else(|_| "[]".to_string());
17 + let birthday_str = contact.birthday.map(|d| d.format("%Y-%m-%d").to_string());
18 +
19 + execute(
20 + conn,
21 + r"
22 + INSERT INTO contacts (id, user_id, display_name, nickname, company, title, notes, tags, birthday, timezone, is_implicit, created_at, updated_at)
23 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
24 + ",
25 + params![
26 + id.to_string(),
27 + user_id.to_string(),
28 + &contact.display_name,
29 + &contact.nickname,
30 + &contact.company,
31 + &contact.title,
32 + &contact.notes,
33 + &tags_json,
34 + &birthday_str,
35 + &contact.timezone,
36 + i32::from(contact.is_implicit),
37 + &now,
38 + &now
39 + ],
40 + )?;
41 +
42 + query::get_by_id(conn, id, user_id)?
43 + .ok_or_else(|| CoreError::internal("Failed to retrieve created contact"))
44 + }
45 +
46 + pub(super) fn restore(conn: &Connection, user_id: UserId, contact: &Contact) -> Result<()> {
47 + let tags_json = serde_json::to_string(&contact.tags).unwrap_or_else(|_| "[]".to_string());
48 + let birthday_str = contact.birthday.map(|d| d.format("%Y-%m-%d").to_string());
49 +
50 + execute(
51 + conn,
52 + r"
53 + INSERT OR IGNORE INTO contacts (id, user_id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at)
54 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
55 + ",
56 + params![
57 + contact.id.to_string(),
58 + user_id.to_string(),
59 + &contact.display_name,
60 + &contact.nickname,
61 + &contact.company,
62 + &contact.title,
63 + &contact.notes,
64 + &tags_json,
65 + &birthday_str,
66 + &contact.timezone,
67 + &contact.external_source,
68 + &contact.external_id,
69 + i32::from(contact.is_implicit),
70 + format_datetime(&contact.created_at),
71 + format_datetime(&contact.updated_at)
72 + ],
73 + )?;
74 + Ok(())
75 + }
76 +
77 + pub(super) fn update(
78 + conn: &Connection,
79 + id: ContactId,
80 + user_id: UserId,
81 + contact: &UpdateContact,
82 + ) -> Result<Option<Contact>> {
83 + let now = format_datetime_now();
84 + let tags_json = serde_json::to_string(&contact.tags).unwrap_or_else(|_| "[]".to_string());
85 + let birthday_str = contact.birthday.map(|d| d.format("%Y-%m-%d").to_string());
86 +
87 + let result = execute(
88 + conn,
89 + r"
90 + UPDATE contacts
91 + SET display_name = ?, nickname = ?, company = ?, title = ?, notes = ?, tags = ?, birthday = ?, timezone = ?, updated_at = ?
92 + WHERE id = ? AND user_id = ?
93 + ",
94 + params![
95 + &contact.display_name,
96 + &contact.nickname,
97 + &contact.company,
98 + &contact.title,
99 + &contact.notes,
100 + &tags_json,
101 + &birthday_str,
102 + &contact.timezone,
103 + &now,
104 + id.to_string(),
105 + user_id.to_string()
106 + ],
107 + )?;
108 +
109 + if result > 0 {
110 + query::get_by_id(conn, id, user_id)
111 + } else {
112 + Ok(None)
113 + }
114 + }
115 +
116 + pub(super) fn delete(conn: &Connection, id: ContactId, user_id: UserId) -> Result<bool> {
117 + let result = execute(
118 + conn,
119 + "DELETE FROM contacts WHERE id = ? AND user_id = ?",
120 + params![id.to_string(), user_id.to_string()],
121 + )?;
122 +
123 + Ok(result > 0)
124 + }
125 +
126 + pub(super) fn set_external_ref(
127 + conn: &Connection,
128 + id: ContactId,
129 + user_id: UserId,
130 + source: &str,
131 + external_id: &str,
132 + ) -> Result<()> {
133 + execute(
134 + conn,
135 + "UPDATE contacts SET external_source = ?, external_id = ? WHERE id = ? AND user_id = ?",
136 + params![source, external_id, id.to_string(), user_id.to_string()],
137 + )?;
138 +
139 + Ok(())
140 + }
141 +
142 + pub(super) fn delete_many(conn: &Connection, ids: &[ContactId], user_id: UserId) -> Result<u64> {
143 + if ids.is_empty() {
144 + return Ok(0);
145 + }
146 + let user_id_str = user_id.to_string();
147 + let placeholders = bind_placeholders(ids.len());
148 + let sql = format!("DELETE FROM contacts WHERE user_id = ? AND id IN ({placeholders})");
149 + let mut binds: Vec<String> = Vec::with_capacity(ids.len() + 1);
150 + binds.push(user_id_str);
151 + binds.extend(ids.iter().map(std::string::ToString::to_string));
152 + let result = execute(conn, &sql, params_from_iter(binds))?;
153 + Ok(result as u64)
154 + }
155 +
156 + pub(super) fn tag_many(
157 + conn: &Connection,
158 + ids: &[ContactId],
159 + user_id: UserId,
160 + tag: &str,
161 + ) -> Result<u64> {
162 + if ids.is_empty() || tag.is_empty() {
163 + return Ok(0);
164 + }
165 + let user_id_str = user_id.to_string();
166 + let like_pattern = format!("%\"{}\"%", escape_like(tag));
167 + let placeholders = bind_placeholders(ids.len());
168 + // Append tag to JSON array where not already present.
169 + let sql = format!(
170 + r"UPDATE contacts
171 + SET tags = CASE
172 + WHEN tags IS NULL OR tags = '' OR tags = '[]'
173 + THEN json_array(?)
174 + ELSE json_insert(tags, '$[#]', ?)
175 + END,
176 + updated_at = datetime('now')
177 + WHERE user_id = ? AND id IN ({placeholders})
178 + AND (tags IS NULL OR tags NOT LIKE ? ESCAPE '\')",
179 + );
180 + let mut binds: Vec<String> = Vec::with_capacity(ids.len() + 4);
181 + binds.push(tag.to_string());
182 + binds.push(tag.to_string());
183 + binds.push(user_id_str);
184 + binds.extend(ids.iter().map(std::string::ToString::to_string));
185 + binds.push(like_pattern);
186 + let result = execute(conn, &sql, params_from_iter(binds))?;
187 + Ok(result as u64)
188 + }
@@ -1,0 +1,166 @@
1 + //! Batch loading of contact sub-collections.
2 + //!
3 + //! One query per sub-collection table for a whole page of contacts, then a
4 + //! single assembly pass, so listing N contacts costs five queries rather than
5 + //! 4N + 1.
6 +
7 + use goingson_core::{
8 + Contact, ContactCustomField, ContactEmail, ContactId, ContactPhone, Result, SocialHandle,
9 + };
10 + use std::collections::HashMap;
11 +
12 + use rusqlite::{Connection, params_from_iter};
13 +
14 + use crate::utils::{bind_placeholders, parse_uuid, query_all};
15 +
16 + use super::row::{
17 + ContactEmailRow, ContactPhoneRow, ContactRow, CustomFieldRow, SocialHandleRow,
18 + contact_email_from_row, contact_from_row, contact_phone_from_row, custom_field_from_row,
19 + social_handle_from_row,
20 + };
21 +
22 + /// Batch-load emails for a set of contact IDs.
23 + pub(super) fn load_emails_for_contacts(
24 + conn: &Connection,
25 + ids: &[String],
26 + ) -> Result<HashMap<ContactId, Vec<ContactEmail>>> {
27 + if ids.is_empty() {
28 + return Ok(HashMap::new());
29 + }
30 +
31 + let placeholders = bind_placeholders(ids.len());
32 + let sql = format!(
33 + "SELECT id, contact_id, address, label, is_primary FROM contact_emails WHERE contact_id IN ({placeholders}) ORDER BY is_primary DESC, rowid ASC"
34 + );
35 +
36 + let rows = query_all(
37 + conn,
38 + &sql,
39 + params_from_iter(ids.iter()),
40 + ContactEmailRow::from_row,
41 + )?;
42 + let mut map: HashMap<ContactId, Vec<ContactEmail>> = HashMap::new();
43 + for row in rows {
44 + let contact_id: ContactId = parse_uuid(&row.contact_id)?.into();
45 + let email = contact_email_from_row(row)?;
46 + map.entry(contact_id).or_default().push(email);
47 + }
48 + Ok(map)
49 + }
50 +
51 + /// Batch-load phones for a set of contact IDs.
52 + pub(super) fn load_phones_for_contacts(
53 + conn: &Connection,
54 + ids: &[String],
55 + ) -> Result<HashMap<ContactId, Vec<ContactPhone>>> {
56 + if ids.is_empty() {
57 + return Ok(HashMap::new());
58 + }
59 +
60 + let placeholders = bind_placeholders(ids.len());
61 + let sql = format!(
62 + "SELECT id, contact_id, number, label, is_primary FROM contact_phones WHERE contact_id IN ({placeholders}) ORDER BY is_primary DESC, rowid ASC"
63 + );
64 +
65 + let rows = query_all(
66 + conn,
67 + &sql,
68 + params_from_iter(ids.iter()),
69 + ContactPhoneRow::from_row,
70 + )?;
71 + let mut map: HashMap<ContactId, Vec<ContactPhone>> = HashMap::new();
72 + for row in rows {
73 + let contact_id: ContactId = parse_uuid(&row.contact_id)?.into();
74 + let phone = contact_phone_from_row(row)?;
75 + map.entry(contact_id).or_default().push(phone);
76 + }
77 + Ok(map)
78 + }
79 +
80 + /// Batch-load social handles for a set of contact IDs.
81 + pub(super) fn load_social_handles_for_contacts(
82 + conn: &Connection,
83 + ids: &[String],
84 + ) -> Result<HashMap<ContactId, Vec<SocialHandle>>> {
85 + if ids.is_empty() {
86 + return Ok(HashMap::new());
87 + }
88 +
89 + let placeholders = bind_placeholders(ids.len());
90 + let sql = format!(
91 + "SELECT id, contact_id, platform, handle, url FROM contact_social_handles WHERE contact_id IN ({placeholders}) ORDER BY rowid ASC"
92 + );
93 +
94 + let rows = query_all(
95 + conn,
96 + &sql,
97 + params_from_iter(ids.iter()),
98 + SocialHandleRow::from_row,
99 + )?;
100 + let mut map: HashMap<ContactId, Vec<SocialHandle>> = HashMap::new();
101 + for row in rows {
102 + let contact_id: ContactId = parse_uuid(&row.contact_id)?.into();
103 + let handle = social_handle_from_row(row)?;
104 + map.entry(contact_id).or_default().push(handle);
105 + }
106 + Ok(map)
107 + }
108 +
109 + /// Batch-load custom fields for a set of contact IDs.
110 + pub(super) fn load_custom_fields_for_contacts(
111 + conn: &Connection,
112 + ids: &[String],
113 + ) -> Result<HashMap<ContactId, Vec<ContactCustomField>>> {
114 + if ids.is_empty() {
115 + return Ok(HashMap::new());
116 + }
117 +
118 + let placeholders = bind_placeholders(ids.len());
119 + let sql = format!(
120 + "SELECT id, contact_id, label, value, url FROM contact_custom_fields WHERE contact_id IN ({placeholders}) ORDER BY rowid ASC"
121 + );
122 +
123 + let rows = query_all(
124 + conn,
125 + &sql,
126 + params_from_iter(ids.iter()),
127 + CustomFieldRow::from_row,
128 + )?;
129 + let mut map: HashMap<ContactId, Vec<ContactCustomField>> = HashMap::new();
130 + for row in rows {
131 + let contact_id: ContactId = parse_uuid(&row.contact_id)?.into();
132 + let field = custom_field_from_row(row)?;
133 + map.entry(contact_id).or_default().push(field);
134 + }
135 + Ok(map)
136 + }
137 +
138 + /// Hydrate a list of contact rows with their sub-collections.
139 + pub(super) fn hydrate_contacts(conn: &Connection, rows: Vec<ContactRow>) -> Result<Vec<Contact>> {
140 + if rows.is_empty() {
141 + return Ok(vec![]);
142 + }
143 +
144 + let ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
145 + let mut emails_map = load_emails_for_contacts(conn, &ids)?;
146 + let mut phones_map = load_phones_for_contacts(conn, &ids)?;
147 + let mut social_map = load_social_handles_for_contacts(conn, &ids)?;
148 + let mut custom_map = load_custom_fields_for_contacts(conn, &ids)?;
149 +
150 + let mut contacts = Vec::with_capacity(rows.len());
151 + for row in rows {
152 + let id: ContactId = parse_uuid(&row.id)?.into();
153 + let emails = emails_map.remove(&id).unwrap_or_default();
154 + let phones = phones_map.remove(&id).unwrap_or_default();
155 + let social_handles = social_map.remove(&id).unwrap_or_default();
156 + let custom_fields = custom_map.remove(&id).unwrap_or_default();
157 + contacts.push(contact_from_row(
158 + row,
159 + emails,
160 + phones,
161 + social_handles,
162 + custom_fields,
163 + )?);
164 + }
165 + Ok(contacts)
166 + }
@@ -1,0 +1,278 @@
1 + //! SQLite implementation of the ContactRepository.
2 + //!
3 + //! Manages contacts with sub-collections (emails, phones, social handles).
4 + //! Sub-collections are stored in separate tables and batch-loaded for list operations.
5 +
6 + mod crud;
7 + mod hydrate;
8 + mod query;
9 + mod row;
10 + mod subcollections;
11 +
12 + use goingson_core::{
13 + Contact, ContactCustomField, ContactEmail, ContactEmailEntry, ContactEmailId, ContactId,
14 + ContactPhone, ContactPhoneId, ContactRepository, CustomFieldId, NewContact,
15 + NewContactCustomField, NewContactEmail, NewContactPhone, NewSocialHandle, Result, SocialHandle,
16 + SocialHandleId, UpdateContact, UserId,
17 + };
18 + use std::collections::HashSet;
19 +
20 + use crate::Db;
21 +
22 + /// SQLite-backed implementation of [`ContactRepository`].
23 + pub struct SqliteContactRepository {
24 + db: Db,
25 + }
26 +
27 + impl SqliteContactRepository {
28 + /// Creates a new repository instance with the given connection pool.
29 + #[tracing::instrument(skip_all)]
30 + pub fn new(db: Db) -> Self {
31 + Self { db }
32 + }
33 + }
34 +
35 + impl ContactRepository for SqliteContactRepository {
36 + #[tracing::instrument(skip_all)]
37 + fn list_all(&self, user_id: UserId) -> Result<Vec<Contact>> {
38 + let conn = self.db.conn()?;
39 + query::list_all(&conn, user_id)
40 + }
41 +
42 + fn list_all_for_backup(&self, user_id: UserId) -> Result<Vec<Contact>> {
43 + let conn = self.db.conn()?;
44 + query::list_all_for_backup(&conn, user_id)
45 + }
46 +
47 + #[tracing::instrument(skip_all)]
48 + fn get_by_id(&self, id: ContactId, user_id: UserId) -> Result<Option<Contact>> {
49 + let conn = self.db.conn()?;
50 + query::get_by_id(&conn, id, user_id)
51 + }
52 +
53 + #[tracing::instrument(skip_all)]
54 + fn create(&self, user_id: UserId, contact: NewContact) -> Result<Contact> {
55 + let conn = self.db.conn()?;
56 + crud::create(&conn, user_id, &contact)
57 + }
58 +
59 + #[tracing::instrument(skip_all)]
60 + fn restore(&self, user_id: UserId, contact: &Contact) -> Result<()> {
61 + let conn = self.db.conn()?;
62 + crud::restore(&conn, user_id, contact)
63 + }
64 +
65 + #[tracing::instrument(skip_all)]
66 + fn update(
67 + &self,
68 + id: ContactId,
69 + user_id: UserId,
70 + contact: UpdateContact,
71 + ) -> Result<Option<Contact>> {
72 + let conn = self.db.conn()?;
73 + crud::update(&conn, id, user_id, &contact)
74 + }
75 +
76 + #[tracing::instrument(skip_all)]
77 + fn delete(&self, id: ContactId, user_id: UserId) -> Result<bool> {
78 + let conn = self.db.conn()?;
79 + crud::delete(&conn, id, user_id)
80 + }
81 +
82 + #[tracing::instrument(skip_all)]
83 + fn set_external_ref(
84 + &self,
85 + id: ContactId,
86 + user_id: UserId,
87 + source: &str,
88 + external_id: &str,
89 + ) -> Result<()> {
90 + let conn = self.db.conn()?;
91 + crud::set_external_ref(&conn, id, user_id, source, external_id)
92 + }
93 +
94 + #[tracing::instrument(skip_all)]
95 + fn delete_many(&self, ids: &[ContactId], user_id: UserId) -> Result<u64> {
96 + let conn = self.db.conn()?;
97 + crud::delete_many(&conn, ids, user_id)
98 + }
99 +
100 + #[tracing::instrument(skip_all)]
101 + fn tag_many(&self, ids: &[ContactId], user_id: UserId, tag: &str) -> Result<u64> {
102 + let conn = self.db.conn()?;
103 + crud::tag_many(&conn, ids, user_id, tag)
104 + }
105 +
106 + #[tracing::instrument(skip_all)]
107 + fn list_by_tag(&self, user_id: UserId, tag: &str) -> Result<Vec<Contact>> {
108 + let conn = self.db.conn()?;
109 + query::list_by_tag(&conn, user_id, tag)
110 + }
111 +
112 + #[tracing::instrument(skip_all)]
113 + fn list_filtered(
114 + &self,
115 + user_id: UserId,
116 + search: Option<&str>,
117 + tag: Option<&str>,
118 + include_implicit: bool,
119 + ) -> Result<Vec<Contact>> {
120 + let conn = self.db.conn()?;
121 + query::list_filtered(&conn, user_id, search, tag, include_implicit)
122 + }
123 +
124 + #[tracing::instrument(skip_all)]
125 + fn list_email_directory(
126 + &self,
127 + user_id: UserId,
128 + include_implicit: bool,
129 + ) -> Result<Vec<ContactEmailEntry>> {
130 + let conn = self.db.conn()?;
131 + query::list_email_directory(&conn, user_id, include_implicit)
132 + }
133 +
134 + #[tracing::instrument(skip_all)]
135 + fn find_by_email(&self, user_id: UserId, email: &str) -> Result<Option<Contact>> {
136 + let conn = self.db.conn()?;
137 + query::find_by_email(&conn, user_id, email)
138 + }
139 +
140 + #[tracing::instrument(skip_all)]
141 + fn find_emails_in_contacts(
142 + &self,
143 + user_id: UserId,
144 + addresses: &[&str],
145 + ) -> Result<HashSet<String>> {
146 + let conn = self.db.conn()?;
147 + query::find_emails_in_contacts(&conn, user_id, addresses)
148 + }
149 +
150 + #[tracing::instrument(skip_all)]
151 + fn promote_contact(&self, id: ContactId, user_id: UserId) -> Result<Option<Contact>> {
152 + let conn = self.db.conn()?;
153 + query::promote_contact(&conn, id, user_id)
154 + }
155 +
156 + #[tracing::instrument(skip_all)]
157 + fn find_by_external_id(
158 + &self,
159 + source: &str,
160 + ext_id: &str,
161 + user_id: UserId,
162 + ) -> Result<Option<Contact>> {
163 + let conn = self.db.conn()?;
164 + query::find_by_external_id(&conn, source, ext_id, user_id)
165 + }
166 +
167 + #[tracing::instrument(skip_all)]
168 + fn add_email(
169 + &self,
170 + contact_id: ContactId,
171 + user_id: UserId,
172 + email: NewContactEmail,
173 + ) -> Result<ContactEmail> {
174 + let conn = self.db.conn()?;
175 + subcollections::add_email(&conn, contact_id, user_id, email)
176 + }
177 +
178 + #[tracing::instrument(skip_all)]
179 + fn remove_email(&self, email_id: ContactEmailId, user_id: UserId) -> Result<bool> {
180 + let conn = self.db.conn()?;
181 + subcollections::remove_email(&conn, email_id, user_id)
182 + }
183 +
184 + #[tracing::instrument(skip_all)]
185 + fn add_phone(
186 + &self,
187 + contact_id: ContactId,
188 + user_id: UserId,
189 + phone: NewContactPhone,
190 + ) -> Result<ContactPhone> {
191 + let conn = self.db.conn()?;
192 + subcollections::add_phone(&conn, contact_id, user_id, phone)
193 + }
194 +
195 + #[tracing::instrument(skip_all)]
196 + fn remove_phone(&self, phone_id: ContactPhoneId, user_id: UserId) -> Result<bool> {
197 + let conn = self.db.conn()?;
198 + subcollections::remove_phone(&conn, phone_id, user_id)
199 + }
200 +
201 + #[tracing::instrument(skip_all)]
202 + fn add_social_handle(
203 + &self,
204 + contact_id: ContactId,
205 + user_id: UserId,
206 + handle: NewSocialHandle,
207 + ) -> Result<SocialHandle> {
208 + let conn = self.db.conn()?;
209 + subcollections::add_social_handle(&conn, contact_id, user_id, handle)
210 + }
211 +
212 + #[tracing::instrument(skip_all)]
213 + fn remove_social_handle(&self, handle_id: SocialHandleId, user_id: UserId) -> Result<bool> {
214 + let conn = self.db.conn()?;
215 + subcollections::remove_social_handle(&conn, handle_id, user_id)
216 + }
217 +
218 + #[tracing::instrument(skip_all)]
219 + fn add_custom_field(
220 + &self,
221 + contact_id: ContactId,
222 + user_id: UserId,
223 + field: NewContactCustomField,
224 + ) -> Result<ContactCustomField> {
225 + let conn = self.db.conn()?;
226 + subcollections::add_custom_field(&conn, contact_id, user_id, field)
227 + }
228 +
229 + #[tracing::instrument(skip_all)]
230 + fn remove_custom_field(&self, field_id: CustomFieldId, user_id: UserId) -> Result<bool> {
231 + let conn = self.db.conn()?;
232 + subcollections::remove_custom_field(&conn, field_id, user_id)
233 + }
234 +
235 + #[tracing::instrument(skip_all)]
236 + fn update_email(
237 + &self,
238 + email_id: ContactEmailId,
239 + user_id: UserId,
240 + email: NewContactEmail,
241 + ) -> Result<Option<ContactEmail>> {
242 + let conn = self.db.conn()?;
243 + subcollections::update_email(&conn, email_id, user_id, &email)
244 + }
245 +
246 + #[tracing::instrument(skip_all)]
247 + fn update_phone(
248 + &self,
249 + phone_id: ContactPhoneId,
250 + user_id: UserId,
251 + phone: NewContactPhone,
252 + ) -> Result<Option<ContactPhone>> {
253 + let conn = self.db.conn()?;
254 + subcollections::update_phone(&conn, phone_id, user_id, &phone)
255 + }
256 +
257 + #[tracing::instrument(skip_all)]
258 + fn update_social_handle(
259 + &self,
260 + handle_id: SocialHandleId,
261 + user_id: UserId,
262 + handle: NewSocialHandle,
263 + ) -> Result<Option<SocialHandle>> {
264 + let conn = self.db.conn()?;
265 + subcollections::update_social_handle(&conn, handle_id, user_id, &handle)
266 + }
267 +
268 + #[tracing::instrument(skip_all)]
269 + fn update_custom_field(
270 + &self,
271 + field_id: CustomFieldId,
272 + user_id: UserId,
273 + field: NewContactCustomField,
274 + ) -> Result<Option<ContactCustomField>> {
275 + let conn = self.db.conn()?;
276 + subcollections::update_custom_field(&conn, field_id, user_id, &field)
277 + }
278 + }
@@ -1,0 +1,272 @@
1 + //! Read paths for contacts.
2 + //!
3 + //! Every function takes an already-checked-out connection, so the parent trait
4 + //! impl owns the pool checkout. `promote_contact` writes a single flag but lives
5 + //! here: it is the terminal step of implicit-contact resolution and shares its
6 + //! query shape with `find_by_email`.
7 +
8 + use goingson_core::{Contact, ContactEmailEntry, ContactId, Result, UserId};
9 + use std::collections::HashSet;
10 +
11 + use rusqlite::{Connection, params, params_from_iter};
12 +
13 + use crate::utils::{
14 + bind_placeholders, escape_like, execute, format_datetime_now, query_all, query_opt,
15 + };
16 +
17 + use super::hydrate;
18 + use super::row::ContactRow;
19 +
20 + pub(super) fn list_all(conn: &Connection, user_id: UserId) -> Result<Vec<Contact>> {
21 + let rows = query_all(
22 + conn,
23 + r"
24 + SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at
25 + FROM contacts
26 + WHERE user_id = ? AND is_implicit = 0
27 + ORDER BY display_name ASC
28 + ",
29 + params![user_id.to_string()],
30 + ContactRow::from_row,
31 + )?;
32 +
33 + hydrate::hydrate_contacts(conn, rows)
34 + }
35 +
36 + pub(super) fn list_all_for_backup(conn: &Connection, user_id: UserId) -> Result<Vec<Contact>> {
37 + let rows = query_all(
38 + conn,
39 + r"
40 + SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at
41 + FROM contacts
42 + WHERE user_id = ?
43 + ORDER BY display_name ASC
44 + ",
45 + params![user_id.to_string()],
46 + ContactRow::from_row,
47 + )?;
48 +
49 + hydrate::hydrate_contacts(conn, rows)
50 + }
51 +
52 + pub(super) fn get_by_id(
53 + conn: &Connection,
54 + id: ContactId,
55 + user_id: UserId,
56 + ) -> Result<Option<Contact>> {
57 + let row = query_opt(
58 + conn,
59 + r"
60 + SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at
61 + FROM contacts
62 + WHERE id = ? AND user_id = ?
63 + ",
64 + params![id.to_string(), user_id.to_string()],
65 + ContactRow::from_row,
66 + )?;
67 +
68 + match row {
69 + Some(r) => {
70 + let contacts = hydrate::hydrate_contacts(conn, vec![r])?;
71 + Ok(contacts.into_iter().next())
72 + }
73 + None => Ok(None),
74 + }
75 + }
76 +
77 + pub(super) fn list_by_tag(conn: &Connection, user_id: UserId, tag: &str) -> Result<Vec<Contact>> {
78 + // Tags stored as JSON array, use LIKE for matching
79 + let pattern = format!("%\"{}\"%", escape_like(tag));
80 + let rows = query_all(
81 + conn,
82 + r"
83 + SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at
84 + FROM contacts
85 + WHERE user_id = ? AND tags LIKE ? ESCAPE '\'
86 + ORDER BY display_name ASC
87 + ",
88 + params![user_id.to_string(), &pattern],
89 + ContactRow::from_row,
90 + )?;
91 +
92 + hydrate::hydrate_contacts(conn, rows)
93 + }
94 +
95 + pub(super) fn list_filtered(
96 + conn: &Connection,
97 + user_id: UserId,
98 + search: Option<&str>,
99 + tag: Option<&str>,
100 + include_implicit: bool,
101 + ) -> Result<Vec<Contact>> {
102 + let has_search = search.is_some_and(|s| !s.is_empty());
103 + let has_tag = tag.is_some_and(|t| !t.is_empty());
104 +
105 + if !has_search && !has_tag && !include_implicit {
106 + return list_all(conn, user_id);
107 + }
108 +
109 + let mut conditions = vec!["c.user_id = ?".to_string()];
110 + if !include_implicit {
111 + conditions.push("c.is_implicit = 0".to_string());
112 + }
113 + let mut binds: Vec<String> = vec![user_id.to_string()];
114 +
115 + if let Some(t) = tag.filter(|t| !t.is_empty()) {
116 + conditions.push("c.tags LIKE ? ESCAPE '\\'".to_string());
117 + binds.push(format!("%\"{}\"%", escape_like(t)));
118 + }
119 +
120 + if let Some(s) = search.filter(|s| !s.is_empty()) {
121 + let search_pattern = format!("%{}%", escape_like(&s.to_lowercase()));
122 + // Search across contact fields and email addresses using a subquery
123 + conditions.push(
124 + "(LOWER(c.display_name) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(c.nickname, '')) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(c.company, '')) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(c.title, '')) LIKE ? ESCAPE '\\' OR LOWER(c.notes) LIKE ? ESCAPE '\\' OR EXISTS (SELECT 1 FROM contact_emails ce WHERE ce.contact_id = c.id AND LOWER(ce.address) LIKE ? ESCAPE '\\'))".to_string()
125 + );
126 + // 6 binds for the search pattern
127 + for _ in 0..6 {
128 + binds.push(search_pattern.clone());
129 + }
130 + }
131 +
132 + let sql = format!(
133 + "SELECT c.id, c.display_name, c.nickname, c.company, c.title, c.notes, c.tags, c.birthday, c.timezone, c.external_source, c.external_id, c.is_implicit, c.created_at, c.updated_at FROM contacts c WHERE {} ORDER BY c.display_name ASC",
134 + conditions.join(" AND ")
135 + );
136 +
137 + let rows = query_all(
138 + conn,
139 + &sql,
140 + params_from_iter(binds.iter()),
141 + ContactRow::from_row,
142 + )?;
143 + hydrate::hydrate_contacts(conn, rows)
144 + }
145 +
146 + pub(super) fn list_email_directory(
147 + conn: &Connection,
148 + user_id: UserId,
149 + include_implicit: bool,
150 + ) -> Result<Vec<ContactEmailEntry>> {
151 + // One JOIN, one row per email address, no per-contact sub-collection
152 + // hydration (the compose autocomplete only needs name + address).
153 + let implicit_filter = if include_implicit {
154 + ""
155 + } else {
156 + "AND c.is_implicit = 0"
157 + };
158 + let sql = format!(
159 + "SELECT c.display_name, ce.address, c.is_implicit \
160 + FROM contacts c JOIN contact_emails ce ON ce.contact_id = c.id \
161 + WHERE c.user_id = ? {implicit_filter} \
162 + ORDER BY c.display_name ASC, ce.is_primary DESC"
163 + );
164 + let rows: Vec<(String, String, i64)> =
165 + query_all(conn, &sql, params![user_id.to_string()], |row| {
166 + Ok((row.get(0)?, row.get(1)?, row.get(2)?))
167 + })?;
168 + Ok(rows
169 + .into_iter()
170 + .map(|(name, email, is_implicit)| ContactEmailEntry {
171 + name,
172 + email,
173 + is_implicit: is_implicit != 0,
174 + })
175 + .collect())
176 + }
177 +
178 + pub(super) fn find_by_email(
179 + conn: &Connection,
180 + user_id: UserId,
181 + email: &str,
182 + ) -> Result<Option<Contact>> {
183 + let row = query_opt(
184 + conn,
185 + r"
186 + SELECT c.id, c.display_name, c.nickname, c.company, c.title, c.notes, c.tags, c.birthday, c.timezone, c.external_source, c.external_id, c.is_implicit, c.created_at, c.updated_at
187 + FROM contacts c
188 + JOIN contact_emails ce ON ce.contact_id = c.id
189 + WHERE c.user_id = ? AND LOWER(ce.address) = LOWER(?)
190 + LIMIT 1
191 + ",
192 + params![user_id.to_string(), email],
193 + ContactRow::from_row,
194 + )?;
195 +
196 + match row {
197 + Some(r) => {
198 + let contacts = hydrate::hydrate_contacts(conn, vec![r])?;
199 + Ok(contacts.into_iter().next())
200 + }
201 + None => Ok(None),
202 + }
203 + }
204 +
205 + pub(super) fn find_emails_in_contacts(
206 + conn: &Connection,
207 + user_id: UserId,
208 + addresses: &[&str],
209 + ) -> Result<HashSet<String>> {
210 + if addresses.is_empty() {
211 + return Ok(HashSet::new());
212 + }
213 +
214 + let placeholders = bind_placeholders(addresses.len());
215 + let query = format!(
216 + "SELECT DISTINCT LOWER(ce.address) FROM contact_emails ce JOIN contacts c ON ce.contact_id = c.id WHERE c.user_id = ? AND LOWER(ce.address) IN ({placeholders})"
217 + );
218 +
219 + let mut binds: Vec<String> = Vec::with_capacity(addresses.len() + 1);
220 + binds.push(user_id.to_string());
221 + binds.extend(addresses.iter().map(|a| a.to_lowercase()));
222 +
223 + let rows: Vec<String> = query_all(conn, &query, params_from_iter(binds), |row| row.get(0))?;
224 +
225 + Ok(rows.into_iter().collect())
226 + }
227 +
228 + pub(super) fn promote_contact(
229 + conn: &Connection,
230 + id: ContactId,
231 + user_id: UserId,
232 + ) -> Result<Option<Contact>> {
233 + let now = format_datetime_now();
234 + let result = execute(
235 + conn,
236 + "UPDATE contacts SET is_implicit = 0, updated_at = ? WHERE id = ? AND user_id = ?",
237 + params![&now, id.to_string(), user_id.to_string()],
238 + )?;
239 +
240 + if result > 0 {
241 + get_by_id(conn, id, user_id)
242 + } else {
243 + Ok(None)
244 + }
245 + }
246 +
247 + pub(super) fn find_by_external_id(
248 + conn: &Connection,
249 + source: &str,
250 + ext_id: &str,
251 + user_id: UserId,
252 + ) -> Result<Option<Contact>> {
253 + let row = query_opt(
254 + conn,
255 + r"
256 + SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at
257 + FROM contacts
258 + WHERE user_id = ? AND external_source = ? AND external_id = ?
259 + LIMIT 1
260 + ",
261 + params![user_id.to_string(), source, ext_id],
262 + ContactRow::from_row,
263 + )?;
264 +
265 + match row {
266 + Some(r) => {
267 + let contacts = hydrate::hydrate_contacts(conn, vec![r])?;
268 + Ok(contacts.into_iter().next())
269 + }
270 + None => Ok(None),
271 + }
272 + }
@@ -1,0 +1,214 @@
1 + //! Row structs for the five contact tables and their conversions to core models.
2 + //!
3 + //! Leaf module: it knows rusqlite and the core models, and nothing else in the crate.
4 +
5 + use goingson_core::{
6 + Contact, ContactCustomField, ContactEmail, ContactPhone, CoreError, Result, SocialHandle,
7 + };
8 +
9 + use crate::utils::{parse_datetime, parse_tags, parse_uuid};
10 +
11 + // Row Structs
12 +
13 + #[derive(Debug, Clone)]
14 + pub(super) struct ContactRow {
15 + pub id: String,
16 + pub display_name: String,
17 + pub nickname: Option<String>,
18 + pub company: Option<String>,
19 + pub title: Option<String>,
20 + pub notes: String,
21 + pub tags: String,
22 + pub birthday: Option<String>,
23 + pub timezone: Option<String>,
24 + pub external_source: Option<String>,
25 + pub external_id: Option<String>,
26 + pub is_implicit: i32,
27 + pub created_at: String,
28 + pub updated_at: String,
29 + }
30 +
31 + impl ContactRow {
32 + pub(super) fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
33 + Ok(Self {
34 + id: row.get("id")?,
35 + display_name: row.get("display_name")?,
36 + nickname: row.get("nickname")?,
37 + company: row.get("company")?,
38 + title: row.get("title")?,
39 + notes: row.get("notes")?,
40 + tags: row.get("tags")?,
41 + birthday: row.get("birthday")?,
42 + timezone: row.get("timezone")?,
43 + external_source: row.get("external_source")?,
44 + external_id: row.get("external_id")?,
45 + is_implicit: row.get("is_implicit")?,
46 + created_at: row.get("created_at")?,
47 + updated_at: row.get("updated_at")?,
48 + })
49 + }
50 + }
51 +
52 + #[derive(Debug, Clone)]
53 + pub(super) struct ContactEmailRow {
54 + pub id: String,
55 + pub contact_id: String,
56 + pub address: String,
57 + pub label: String,
58 + pub is_primary: i32,
59 + }
60 +
61 + impl ContactEmailRow {
62 + pub(super) fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
63 + Ok(Self {
64 + id: row.get("id")?,
65 + contact_id: row.get("contact_id")?,
66 + address: row.get("address")?,
67 + label: row.get("label")?,
68 + is_primary: row.get("is_primary")?,
69 + })
70 + }
71 + }
72 +
73 + #[derive(Debug, Clone)]
74 + pub(super) struct ContactPhoneRow {
75 + pub id: String,
76 + pub contact_id: String,
77 + pub number: String,
78 + pub label: String,
79 + pub is_primary: i32,
80 + }
81 +
82 + impl ContactPhoneRow {
83 + pub(super) fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
84 + Ok(Self {
85 + id: row.get("id")?,
86 + contact_id: row.get("contact_id")?,
87 + number: row.get("number")?,
88 + label: row.get("label")?,
89 + is_primary: row.get("is_primary")?,
90 + })
91 + }
92 + }
93 +
94 + #[derive(Debug, Clone)]
95 + pub(super) struct SocialHandleRow {
96 + pub id: String,
97 + pub contact_id: String,
98 + pub platform: String,
99 + pub handle: String,
100 + pub url: Option<String>,
101 + }
102 +
103 + impl SocialHandleRow {
104 + pub(super) fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
105 + Ok(Self {
106 + id: row.get("id")?,
107 + contact_id: row.get("contact_id")?,
108 + platform: row.get("platform")?,
109 + handle: row.get("handle")?,
110 + url: row.get("url")?,
111 + })
112 + }
113 + }
114 +
115 + #[derive(Debug, Clone)]
116 + pub(super) struct CustomFieldRow {
117 + pub id: String,
118 + pub contact_id: String,
119 + pub label: String,
120 + pub value: String,
121 + pub url: Option<String>,
122 + }
123 +
124 + impl CustomFieldRow {
125 + pub(super) fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
126 + Ok(Self {
127 + id: row.get("id")?,
128 + contact_id: row.get("contact_id")?,
129 + label: row.get("label")?,
130 + value: row.get("value")?,
131 + url: row.get("url")?,
132 + })
133 + }
134 + }
135 +
136 + // Row Conversions
137 +
138 + pub(super) fn contact_email_from_row(row: ContactEmailRow) -> Result<ContactEmail> {
139 + Ok(ContactEmail {
140 + id: parse_uuid(&row.id)?.into(),
141 + contact_id: parse_uuid(&row.contact_id)?.into(),
142 + address: row.address,
143 + label: row.label,
144 + is_primary: row.is_primary != 0,
145 + })
146 + }
147 +
148 + pub(super) fn contact_phone_from_row(row: ContactPhoneRow) -> Result<ContactPhone> {
149 + Ok(ContactPhone {
150 + id: parse_uuid(&row.id)?.into(),
151 + contact_id: parse_uuid(&row.contact_id)?.into(),
152 + number: row.number,
153 + label: row.label,
154 + is_primary: row.is_primary != 0,
155 + })
156 + }
157 +
158 + pub(super) fn social_handle_from_row(row: SocialHandleRow) -> Result<SocialHandle> {
159 + Ok(SocialHandle {
160 + id: parse_uuid(&row.id)?.into(),
161 + contact_id: parse_uuid(&row.contact_id)?.into(),
162 + platform: row.platform,
163 + handle: row.handle,
164 + url: row.url,
165 + })
166 + }
167 +
168 + pub(super) fn custom_field_from_row(row: CustomFieldRow) -> Result<ContactCustomField> {
169 + Ok(ContactCustomField {
170 + id: parse_uuid(&row.id)?.into(),
171 + contact_id: parse_uuid(&row.contact_id)?.into(),
172 + label: row.label,
173 + value: row.value,
174 + url: row.url,
175 + })
176 + }
177 +
178 + pub(super) fn contact_from_row(
179 + row: ContactRow,
180 + emails: Vec<ContactEmail>,
181 + phones: Vec<ContactPhone>,
182 + social_handles: Vec<SocialHandle>,
183 + custom_fields: Vec<ContactCustomField>,
184 + ) -> Result<Contact> {
185 + let birthday = row
186 + .birthday
187 + .as_deref()
188 + .map(|s| {
189 + chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
190 + .map_err(|e| CoreError::database_msg(format!("Invalid birthday: {e}")))
191 + })
192 + .transpose()?;
193 +
194 + Ok(Contact {
195 + id: parse_uuid(&row.id)?.into(),
196 + display_name: row.display_name,
197 + nickname: row.nickname,
198 + company: row.company,
199 + title: row.title,
200 + notes: row.notes,
201 + tags: parse_tags(&row.tags),
202 + birthday,
203 + timezone: row.timezone,
204 + external_source: row.external_source,
205 + external_id: row.external_id,
206 + is_implicit: row.is_implicit != 0,
207 + emails,
208 + phones,
209 + social_handles,
210 + custom_fields,
211 + created_at: parse_datetime(&row.created_at)?,
212 + updated_at: parse_datetime(&row.updated_at)?,
213 + })
214 + }
@@ -1,0 +1,421 @@
1 + //! Mutations over the four contact sub-collection tables.
2 + //!
3 + //! Twelve functions that are three shapes (add, remove, update) replicated over
4 + //! emails, phones, social handles and custom fields. They stay in one module so
5 + //! the repetition stays visible rather than spread over four files that each say
6 + //! the same thing.
7 +
8 + use goingson_core::{
9 + ContactCustomField, ContactEmail, ContactEmailId, ContactId, ContactPhone, ContactPhoneId,
10 + CoreError, CustomFieldId, NewContactCustomField, NewContactEmail, NewContactPhone,
11 + NewSocialHandle, Result, SocialHandle, SocialHandleId, UserId,
12 + };
13 +
14 + use rusqlite::{Connection, params};
15 +
16 + use crate::utils::execute;
17 +
18 + pub(super) fn add_email(
19 + conn: &Connection,
20 + contact_id: ContactId,
21 + user_id: UserId,
22 + email: NewContactEmail,
23 + ) -> Result<ContactEmail> {
24 + // Verify contact ownership
25 + let exists: i64 = conn
26 + .query_row(
27 + "SELECT COUNT(*) FROM contacts WHERE id = ? AND user_id = ?",
28 + params![contact_id.to_string(), user_id.to_string()],
29 + |row| row.get(0),
30 + )
31 + .map_err(CoreError::database)?;
32 +
33 + if exists == 0 {
34 + return Err(CoreError::not_found("contact", contact_id));
35 + }
36 +
37 + let id = ContactEmailId::new();
38 + execute(
39 + conn,
40 + "INSERT INTO contact_emails (id, contact_id, address, label, is_primary) VALUES (?, ?, ?, ?, ?)",
41 + params![
42 + id.to_string(),
43 + contact_id.to_string(),
44 + &email.address,
45 + &email.label,
46 + email.is_primary as i32
47 + ],
48 + )?;
49 +
50 + Ok(ContactEmail {
51 + id,
52 + contact_id,
53 + address: email.address,
54 + label: email.label,
55 + is_primary: email.is_primary,
56 + })
57 + }
58 +
59 + pub(super) fn remove_email(
60 + conn: &Connection,
61 + email_id: ContactEmailId,
62 + user_id: UserId,
63 + ) -> Result<bool> {
64 + // Verify ownership via JOIN
65 + let result = execute(
66 + conn,
67 + r"
68 + DELETE FROM contact_emails
69 + WHERE id = ? AND contact_id IN (SELECT id FROM contacts WHERE user_id = ?)
70 + ",
71 + params![email_id.to_string(), user_id.to_string()],
72 + )?;
73 +
74 + Ok(result > 0)
75 + }
76 +
77 + pub(super) fn update_email(
78 + conn: &Connection,
79 + email_id: ContactEmailId,
80 + user_id: UserId,
81 + email: &NewContactEmail,
82 + ) -> Result<Option<ContactEmail>> {
83 + let result = execute(
84 + conn,
85 + r"
86 + UPDATE contact_emails
87 + SET address = ?, label = ?, is_primary = ?
88 + WHERE id = ? AND contact_id IN (SELECT id FROM contacts WHERE user_id = ?)
89 + ",
90 + params![
91 + &email.address,
92 + &email.label,
93 + email.is_primary as i32,
94 + email_id.to_string(),
95 + user_id.to_string()
96 + ],
97 + )?;
98 +
99 + if result == 0 {
100 + return Ok(None);
101 + }
102 +
103 + let row: (String, String, String, i32) = conn
104 + .query_row(
105 + "SELECT contact_id, address, label, is_primary FROM contact_emails WHERE id = ?",
106 + params![email_id.to_string()],
107 + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
108 + )
109 + .map_err(CoreError::database)?;
110 +
111 + Ok(Some(ContactEmail {
112 + id: email_id,
113 + contact_id: crate::utils::parse_uuid(&row.0)?.into(),
114 + address: row.1,
115 + label: row.2,
116 + is_primary: row.3 != 0,
117 + }))
118 + }
119 +
120 + pub(super) fn add_phone(
121 + conn: &Connection,
122 + contact_id: ContactId,
123 + user_id: UserId,
124 + phone: NewContactPhone,
125 + ) -> Result<ContactPhone> {
126 + // Verify contact ownership
127 + let exists: i64 = conn
128 + .query_row(
129 + "SELECT COUNT(*) FROM contacts WHERE id = ? AND user_id = ?",
130 + params![contact_id.to_string(), user_id.to_string()],
131 + |row| row.get(0),
132 + )
133 + .map_err(CoreError::database)?;
134 +
135 + if exists == 0 {
136 + return Err(CoreError::not_found("contact", contact_id));
137 + }
138 +
139 + let id = ContactPhoneId::new();
140 + execute(
141 + conn,
142 + "INSERT INTO contact_phones (id, contact_id, number, label, is_primary) VALUES (?, ?, ?, ?, ?)",
143 + params![
144 + id.to_string(),
145 + contact_id.to_string(),
146 + &phone.number,
147 + &phone.label,
148 + phone.is_primary as i32
149 + ],
150 + )?;
151 +
152 + Ok(ContactPhone {
153 + id,
154 + contact_id,
155 + number: phone.number,
156 + label: phone.label,
157 + is_primary: phone.is_primary,
158 + })
159 + }
160 +
161 + pub(super) fn remove_phone(
162 + conn: &Connection,
163 + phone_id: ContactPhoneId,
164 + user_id: UserId,
165 + ) -> Result<bool> {
166 + let result = execute(
167 + conn,
168 + r"
169 + DELETE FROM contact_phones
170 + WHERE id = ? AND contact_id IN (SELECT id FROM contacts WHERE user_id = ?)
171 + ",
172 + params![phone_id.to_string(), user_id.to_string()],
173 + )?;
174 +
175 + Ok(result > 0)
176 + }
177 +
178 + pub(super) fn update_phone(
179 + conn: &Connection,
180 + phone_id: ContactPhoneId,
181 + user_id: UserId,
182 + phone: &NewContactPhone,
183 + ) -> Result<Option<ContactPhone>> {
184 + let result = execute(
185 + conn,
186 + r"
187 + UPDATE contact_phones
188 + SET number = ?, label = ?, is_primary = ?
189 + WHERE id = ? AND contact_id IN (SELECT id FROM contacts WHERE user_id = ?)
190 + ",
191 + params![
192 + &phone.number,
193 + &phone.label,
194 + phone.is_primary as i32,
195 + phone_id.to_string(),
196 + user_id.to_string()
197 + ],
198 + )?;
199 +
200 + if result == 0 {
201 + return Ok(None);
202 + }
203 +
204 + let row: (String, String, String, i32) = conn
205 + .query_row(
206 + "SELECT contact_id, number, label, is_primary FROM contact_phones WHERE id = ?",
207 + params![phone_id.to_string()],
208 + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
209 + )
210 + .map_err(CoreError::database)?;
211 +
212 + Ok(Some(ContactPhone {
213 + id: phone_id,
214 + contact_id: crate::utils::parse_uuid(&row.0)?.into(),
215 + number: row.1,
216 + label: row.2,
217 + is_primary: row.3 != 0,
218 + }))
219 + }
220 +
221 + pub(super) fn add_social_handle(
222 + conn: &Connection,
223 + contact_id: ContactId,
224 + user_id: UserId,
225 + handle: NewSocialHandle,
226 + ) -> Result<SocialHandle> {
227 + // Verify contact ownership
228 + let exists: i64 = conn
229 + .query_row(
230 + "SELECT COUNT(*) FROM contacts WHERE id = ? AND user_id = ?",
231 + params![contact_id.to_string(), user_id.to_string()],
232 + |row| row.get(0),
233 + )
234 + .map_err(CoreError::database)?;
235 +
236 + if exists == 0 {
237 + return Err(CoreError::not_found("contact", contact_id));
238 + }
239 +
240 + let id = SocialHandleId::new();
241 + execute(
242 + conn,
243 + "INSERT INTO contact_social_handles (id, contact_id, platform, handle, url) VALUES (?, ?, ?, ?, ?)",
244 + params![
245 + id.to_string(),
246 + contact_id.to_string(),
247 + &handle.platform,
248 + &handle.handle,
249 + &handle.url
250 + ],
251 + )?;
252 +
253 + Ok(SocialHandle {
254 + id,
255 + contact_id,
256 + platform: handle.platform,
257 + handle: handle.handle,
258 + url: handle.url,
259 + })
260 + }
261 +
262 + pub(super) fn remove_social_handle(
263 + conn: &Connection,
264 + handle_id: SocialHandleId,
265 + user_id: UserId,
266 + ) -> Result<bool> {
267 + let result = execute(
268 + conn,
269 + r"
270 + DELETE FROM contact_social_handles
271 + WHERE id = ? AND contact_id IN (SELECT id FROM contacts WHERE user_id = ?)
272 + ",
273 + params![handle_id.to_string(), user_id.to_string()],
274 + )?;
275 +
276 + Ok(result > 0)
277 + }
278 +
279 + pub(super) fn update_social_handle(
280 + conn: &Connection,
281 + handle_id: SocialHandleId,
282 + user_id: UserId,
283 + handle: &NewSocialHandle,
284 + ) -> Result<Option<SocialHandle>> {
285 + let result = execute(
286 + conn,
287 + r"
288 + UPDATE contact_social_handles
289 + SET platform = ?, handle = ?, url = ?
290 + WHERE id = ? AND contact_id IN (SELECT id FROM contacts WHERE user_id = ?)
291 + ",
292 + params![
293 + &handle.platform,
294 + &handle.handle,
295 + &handle.url,
296 + handle_id.to_string(),
297 + user_id.to_string()
298 + ],
299 + )?;
300 +
301 + if result == 0 {
302 + return Ok(None);
303 + }
304 +
305 + let row: (String, String, String, Option<String>) = conn
306 + .query_row(
307 + "SELECT contact_id, platform, handle, url FROM contact_social_handles WHERE id = ?",
308 + params![handle_id.to_string()],
309 + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
310 + )
311 + .map_err(CoreError::database)?;
312 +
313 + Ok(Some(SocialHandle {
314 + id: handle_id,
315 + contact_id: crate::utils::parse_uuid(&row.0)?.into(),
316 + platform: row.1,
317 + handle: row.2,
318 + url: row.3,
319 + }))
320 + }
321 +
322 + pub(super) fn add_custom_field(
323 + conn: &Connection,
324 + contact_id: ContactId,
325 + user_id: UserId,
326 + field: NewContactCustomField,
327 + ) -> Result<ContactCustomField> {
328 + // Verify contact ownership
329 + let exists: i64 = conn
330 + .query_row(
331 + "SELECT COUNT(*) FROM contacts WHERE id = ? AND user_id = ?",
332 + params![contact_id.to_string(), user_id.to_string()],
333 + |row| row.get(0),
334 + )
335 + .map_err(CoreError::database)?;
336 +
337 + if exists == 0 {
338 + return Err(CoreError::not_found("contact", contact_id));
339 + }
340 +
341 + let id = CustomFieldId::new();
342 + execute(
343 + conn,
344 + "INSERT INTO contact_custom_fields (id, contact_id, label, value, url) VALUES (?, ?, ?, ?, ?)",
345 + params![
346 + id.to_string(),
347 + contact_id.to_string(),
348 + &field.label,
349 + &field.value,
350 + &field.url
351 + ],
352 + )?;
353 +
354 + Ok(ContactCustomField {
355 + id,
356 + contact_id,
357 + label: field.label,
358 + value: field.value,
359 + url: field.url,
360 + })
361 + }
362 +
363 + pub(super) fn remove_custom_field(
364 + conn: &Connection,
365 + field_id: CustomFieldId,
366 + user_id: UserId,
367 + ) -> Result<bool> {
368 + let result = execute(
369 + conn,
370 + r"
371 + DELETE FROM contact_custom_fields
372 + WHERE id = ? AND contact_id IN (SELECT id FROM contacts WHERE user_id = ?)
373 + ",
374 + params![field_id.to_string(), user_id.to_string()],
375 + )?;
376 +
377 + Ok(result > 0)
378 + }
379 +
380 + pub(super) fn update_custom_field(
381 + conn: &Connection,
382 + field_id: CustomFieldId,
383 + user_id: UserId,
384 + field: &NewContactCustomField,
385 + ) -> Result<Option<ContactCustomField>> {
386 + let result = execute(
387 + conn,
388 + r"
389 + UPDATE contact_custom_fields
390 + SET label = ?, value = ?, url = ?
391 + WHERE id = ? AND contact_id IN (SELECT id FROM contacts WHERE user_id = ?)
392 + ",
393 + params![
394 + &field.label,
395 + &field.value,
396 + &field.url,
397 + field_id.to_string(),
398 + user_id.to_string()
399 + ],
400 + )?;
401 +
402 + if result == 0 {
403 + return Ok(None);
404 + }
405 +
406 + let row: (String, String, String, Option<String>) = conn
407 + .query_row(
408 + "SELECT contact_id, label, value, url FROM contact_custom_fields WHERE id = ?",
409 + params![field_id.to_string()],
410 + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
411 + )
412 + .map_err(CoreError::database)?;
413 +
414 + Ok(Some(ContactCustomField {
415 + id: field_id,
416 + contact_id: crate::utils::parse_uuid(&row.0)?.into(),
417 + label: row.1,
418 + value: row.2,
419 + url: row.3,
420 + }))
421 + }
@@ -1,1209 +1,0 @@
1 - //! SQLite implementation of the EmailRepository.
2 - //!
3 - //! Manages email messages with support for:
4 - //! - IMAP synchronization tracking (message_id, imap_uid)
5 - //! - Threading via in_reply_to and thread_id
6 - //! - Read/archived status
7 - //! - Project associations
8 - //! - Snoozing and waiting-for-response tracking
9 -
10 - use chrono::{DateTime, Utc};
11 - use goingson_core::{
12 - BodyFormat, CoreError, DbValue, Email, EmailAccountId, EmailId, EmailRepository, EmailThread,
13 - NewEmail, NewEmailWithTracking, ParseableEnum, ProjectId, Result, UserId,
14 - };
15 - use std::collections::HashMap;
16 - use std::collections::HashSet;
17 -
18 - use crate::utils::{
19 - bind_placeholders, execute, format_datetime, format_datetime_now, format_datetime_opt,
20 - parse_datetime, parse_uuid, parse_uuid_opt, query_all, query_opt,
21 - };
22 - use rusqlite::{params, params_from_iter};
23 -
24 - use crate::Db;
25 -
26 - /// Column list for SELECT queries - avoids duplication across methods.
27 - const EMAIL_SELECT_COLUMNS: &str = r"e.id, e.project_id, p.name as project_name, e.from_address, e.to_address,
28 - e.subject, e.body, e.body_format, e.html_body, e.is_read, e.is_archived, e.received_at, e.message_id,
29 - e.in_reply_to, e.thread_id, e.email_account_id, e.is_outgoing, e.imap_uid, e.source_folder,
30 - e.attachment_meta, e.labels, e.is_draft, e.cc_address, e.bcc_address, e.draft_account_id,
31 - e.snoozed_until, e.waiting_for_response, e.waiting_since, e.expected_response_date,
32 - e.body_truncated, e.jmap_id,
33 - e.queued_at, e.send_after, e.send_attempts, e.send_error";
34 -
35 - /// Same shape as [`EMAIL_SELECT_COLUMNS`] but with the two heavy body columns
36 - /// blanked, for flat list views that never render a body (Perf S3). `body_truncated`
37 - /// is forced to 1 so the reader knows to re-fetch the full body on open.
38 - const EMAIL_LIST_COLUMNS: &str = r"e.id, e.project_id, p.name as project_name, e.from_address, e.to_address,
39 - e.subject, '' AS body, e.body_format, NULL AS html_body, e.is_read, e.is_archived, e.received_at, e.message_id,
40 - e.in_reply_to, e.thread_id, e.email_account_id, e.is_outgoing, e.imap_uid, e.source_folder,
41 - e.attachment_meta, e.labels, e.is_draft, e.cc_address, e.bcc_address, e.draft_account_id,
42 - e.snoozed_until, e.waiting_for_response, e.waiting_since, e.expected_response_date,
43 - 1 AS body_truncated, e.jmap_id,
44 - e.queued_at, e.send_after, e.send_attempts, e.send_error";
45 -
46 - /// Upper bound on a flat metadata list so it can never materialize an unbounded
47 - /// number of rows (the list UI paginates via `list_threaded`; this flat path is a
48 - /// safety-capped fallback).
49 - const EMAIL_LIST_CAP: i64 = 1000;
50 -
51 - #[derive(Debug, Clone)]
52 - struct EmailRow {
53 - pub id: String,
54 - pub project_id: Option<String>,
55 - pub project_name: Option<String>,
56 - pub from_address: String,
57 - pub to_address: String,
58 - pub subject: String,
59 - pub body: String,
60 - pub body_format: String,
61 - pub html_body: Option<String>,
62 - pub is_read: i32,
63 - pub is_archived: i32,
64 - pub received_at: String,
65 - pub message_id: Option<String>,
66 - pub in_reply_to: Option<String>,
67 - pub thread_id: Option<String>,
68 - pub email_account_id: Option<String>,
69 - pub is_outgoing: i32,
70 - pub imap_uid: Option<i64>,
71 - pub source_folder: Option<String>,
72 - pub attachment_meta: Option<String>,
73 - pub labels: String,
74 - pub is_draft: i32,
75 - pub cc_address: Option<String>,
76 - pub bcc_address: Option<String>,
77 - pub draft_account_id: Option<String>,
78 - pub snoozed_until: Option<String>,
79 - pub waiting_for_response: i32,
80 - pub waiting_since: Option<String>,
81 - pub expected_response_date: Option<String>,
82 - pub body_truncated: i32,
83 - pub jmap_id: Option<String>,
84 - pub queued_at: Option<String>,
85 - pub send_after: Option<String>,
86 - pub send_attempts: i32,
87 - pub send_error: Option<String>,
88 - }
89 -
90 - impl EmailRow {
91 - fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
92 - Ok(Self {
93 - id: row.get("id")?,
94 - project_id: row.get("project_id")?,
95 - project_name: row.get("project_name")?,
96 - from_address: row.get("from_address")?,
97 - to_address: row.get("to_address")?,
98 - subject: row.get("subject")?,
99 - body: row.get("body")?,
100 - body_format: row.get("body_format")?,
101 - html_body: row.get("html_body")?,
102 - is_read: row.get("is_read")?,
103 - is_archived: row.get("is_archived")?,
104 - received_at: row.get("received_at")?,
105 - message_id: row.get("message_id")?,
106 - in_reply_to: row.get("in_reply_to")?,
107 - thread_id: row.get("thread_id")?,
108 - email_account_id: row.get("email_account_id")?,
109 - is_outgoing: row.get("is_outgoing")?,
110 - imap_uid: row.get("imap_uid")?,
111 - source_folder: row.get("source_folder")?,
112 - attachment_meta: row.get("attachment_meta")?,
113 - labels: row.get("labels")?,
114 - is_draft: row.get("is_draft")?,
115 - cc_address: row.get("cc_address")?,
116 - bcc_address: row.get("bcc_address")?,
117 - draft_account_id: row.get("draft_account_id")?,
118 - snoozed_until: row.get("snoozed_until")?,
119 - waiting_for_response: row.get("waiting_for_response")?,
120 - waiting_since: row.get("waiting_since")?,
121 - expected_response_date: row.get("expected_response_date")?,
122 - body_truncated: row.get("body_truncated")?,
123 - jmap_id: row.get("jmap_id")?,
124 - queued_at: row.get("queued_at")?,
125 - send_after: row.get("send_after")?,
126 - send_attempts: row.get("send_attempts")?,
127 - send_error: row.get("send_error")?,
128 - })
129 - }
130 - }
131 -
132 - impl TryFrom<EmailRow> for Email {
133 - type Error = CoreError;
134 -
135 - fn try_from(row: EmailRow) -> std::result::Result<Self, Self::Error> {
136 - Ok(Email {
137 - id: parse_uuid(&row.id)?.into(),
138 - project_id: parse_uuid_opt(row.project_id.as_deref())?.map(Into::into),
139 - project_name: row.project_name,
140 - from: row.from_address,
141 - to: row.to_address,
142 - subject: row.subject,
143 - body: row.body,
144 - body_format: BodyFormat::from_str_or_default(&row.body_format),
145 - html_body: row.html_body,
146 - body_truncated: row.body_truncated != 0,
147 - jmap_id: row.jmap_id,
148 - queued_at: row
149 - .queued_at
150 - .as_ref()
151 - .map(|s| parse_datetime(s))
152 - .transpose()?,
153 - send_after: row
154 - .send_after
155 - .as_ref()
156 - .map(|s| parse_datetime(s))
157 - .transpose()?,
158 - send_attempts: row.send_attempts,
159 - send_error: row.send_error,
160 - is_read: row.is_read != 0,
161 - is_archived: row.is_archived != 0,
162 - received_at: parse_datetime(&row.received_at)?,
163 - message_id: row.message_id,
164 - in_reply_to: row.in_reply_to,
165 - thread_id: row.thread_id,
166 - email_account_id: parse_uuid_opt(row.email_account_id.as_deref())?.map(Into::into),
167 - is_outgoing: row.is_outgoing != 0,
168 - imap_uid: row.imap_uid,
169 - source_folder: row.source_folder,
170 - attachment_meta: row.attachment_meta,
171 - labels: serde_json::from_str(&row.labels).unwrap_or_default(),
172 - is_draft: row.is_draft != 0,
173 - cc_address: row.cc_address,
174 - bcc_address: row.bcc_address,
175 - draft_account_id: parse_uuid_opt(row.draft_account_id.as_deref())?.map(Into::into),
176 - snoozed_until: row
177 - .snoozed_until
178 - .as_ref()
179 - .map(|s| parse_datetime(s))
180 - .transpose()?,
181 - waiting_for_response: row.waiting_for_response != 0,
182 - waiting_since: row
183 - .waiting_since
184 - .as_ref()
185 - .map(|s| parse_datetime(s))
186 - .transpose()?,
187 - expected_response_date: row
188 - .expected_response_date
189 - .as_ref()
190 - .map(|s| parse_datetime(s))
191 - .transpose()?,
192 - })
193 - }
194 - }
195 -
196 - /// SQLite-backed implementation of [`EmailRepository`].
197 - ///
198 - /// Manages email messages with threading support, snoozing, and
199 - /// waiting-for-response tracking. Integrates with IMAP sync via message_id.
200 - pub struct SqliteEmailRepository {
201 - db: Db,
202 - }
203 -
204 - impl SqliteEmailRepository {
205 - /// Creates a new repository instance with the given connection pool.
206 - #[tracing::instrument(skip_all)]
207 - pub fn new(db: Db) -> Self {
208 - Self { db }
209 - }
210 - }
211 -
212 - impl EmailRepository for SqliteEmailRepository {
213 - #[tracing::instrument(skip_all)]
214 - fn list_all_for_backup(&self, user_id: UserId) -> Result<Vec<Email>> {
215 - let conn = self.db.conn()?;
216 - let query = format!(
217 - "SELECT {EMAIL_SELECT_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? ORDER BY e.received_at DESC"
218 - );
219 - let rows = query_all(
220 - &conn,
221 - &query,
222 - params![user_id.to_string(), user_id.to_string()],
223 - EmailRow::from_row,
224 - )?;
225 - rows.into_iter().map(Email::try_from).collect()
226 - }
227 -
228 - fn list_all(&self, user_id: UserId, include_archived: bool) -> Result<Vec<Email>> {
229 - let conn = self.db.conn()?;
230 - let archived_filter = if include_archived {
231 - ""
232 - } else {
233 - "AND e.is_archived = 0"
234 - };
235 - let query = format!(
236 - "SELECT {EMAIL_SELECT_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.is_draft = 0 {archived_filter} ORDER BY e.received_at DESC"
237 - );
238 - let rows = query_all(
239 - &conn,
240 - &query,
241 - params![user_id.to_string(), user_id.to_string()],
242 - EmailRow::from_row,
243 - )?;
244 - rows.into_iter().map(Email::try_from).collect()
245 - }
246 -
247 - #[tracing::instrument(skip_all)]
248 - fn list_metadata(&self, user_id: UserId, include_archived: bool) -> Result<Vec<Email>> {
249 - let conn = self.db.conn()?;
250 - let archived_filter = if include_archived {
251 - ""
252 - } else {
253 - "AND e.is_archived = 0"
254 - };
255 - let query = format!(
256 - "SELECT {EMAIL_LIST_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.is_draft = 0 {archived_filter} ORDER BY e.received_at DESC LIMIT {EMAIL_LIST_CAP}"
257 - );
258 - let rows = query_all(
259 - &conn,
260 - &query,
261 - params![user_id.to_string(), user_id.to_string()],
262 - EmailRow::from_row,
263 - )?;
264 - rows.into_iter().map(Email::try_from).collect()
265 - }
266 -
267 - #[tracing::instrument(skip_all)]
268 - fn list_threaded(
269 - &self,
270 - user_id: UserId,
271 - include_archived: bool,
272 - offset: Option<i64>,
273 - limit: Option<i64>,
274 - folder: Option<&str>,
275 - label: Option<&str>,
276 - ) -> Result<(Vec<EmailThread>, i64)> {
277 - let uid = user_id.to_string();
278 - let archived_filter = if include_archived {
279 - ""
280 - } else {
281 - "AND e.is_archived = 0"
282 - };
283 - let folder_filter = folder.map_or("", |_| "AND e.source_folder = ?");
284 - let label_filter = label.map_or(
285 - "",
286 - |_| "AND EXISTS (SELECT 1 FROM json_each(e.labels) j WHERE j.value = ?)",
287 - );
288 - // Defense-in-depth: clamp before binding. A negative LIMIT means
289 - // unbounded in SQLite (would load the whole mailbox); a negative OFFSET
290 - // is ignored. Matches task_repo/search_repo.
291 - const MAX_PAGE_LIMIT: i64 = 1000;
292 - let offset_val = offset.unwrap_or(0).max(0);
293 - let limit_val = limit.unwrap_or(50).clamp(0, MAX_PAGE_LIMIT);
294 -
295 - // Query 1: Get total thread count
296 - let count_sql = format!(
297 - "SELECT COUNT(DISTINCT COALESCE(e.thread_id, e.id)) FROM emails e WHERE e.user_id = ? AND e.is_draft = 0 {archived_filter} {folder_filter} {label_filter}"
298 - );
299 - // The optional folder/label filters add a placeholder each, so the binds are
300 - // assembled in the same order the filters were spliced into the SQL.
301 - let mut filter_binds: Vec<String> = vec![uid.clone()];
302 - if let Some(f) = folder {
303 - filter_binds.push(f.to_string());
304 - }
305 - if let Some(l) = label {
306 - filter_binds.push(l.to_string());
307 - }
308 -
309 - let conn = self.db.conn()?;
310 - let total: i64 = conn
311 - .query_row(&count_sql, params_from_iter(&filter_binds), |row| {
312 - row.get(0)
313 - })
314 - .map_err(CoreError::database)?;
315 -
316 - if total == 0 {
317 - return Ok((vec![], 0));
318 - }
319 -
320 - // Query 2: Thread summary, group by thread, get latest received_at, count, unread status
321 - #[allow(dead_code)]
322 - struct ThreadSummary {
323 - thread_key: String,
324 - latest_received_at: String, // needed for SQL ORDER BY
325 - thread_count: i64,
326 - unread_count: i64,
327 - latest_email_id: String,
328 - }
329 -
330 - // Rank emails within each thread by recency in a single pass with window
331 - // functions, then keep the latest row per thread. This replaces a
332 - // correlated subquery that rescanned `emails` once per thread group
333 - // (O(threads x emails)); the partition's MAX(received_at) is the rn = 1
334 - // row, and the thread-wide counts come from window aggregates.
335 - let summary_sql = format!(
336 - r"WITH ranked AS (
337 - SELECT
338 - e.id AS email_id,
339 - COALESCE(e.thread_id, e.id) AS thread_key,
340 - e.received_at AS received_at,
341 - ROW_NUMBER() OVER (
342 - PARTITION BY COALESCE(e.thread_id, e.id)
343 - ORDER BY e.received_at DESC, e.id DESC
344 - ) AS rn,
345 - COUNT(*) OVER (PARTITION BY COALESCE(e.thread_id, e.id)) AS thread_count,
346 - SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END)
347 - OVER (PARTITION BY COALESCE(e.thread_id, e.id)) AS unread_count
348 - FROM emails e
349 - WHERE e.user_id = ? AND e.is_draft = 0 {archived_filter} {folder_filter} {label_filter}
350 - )
351 - SELECT
352 - thread_key,
353 - received_at AS latest_received_at,
354 - thread_count,
355 - unread_count,
356 - email_id AS latest_email_id
357 - FROM ranked
358 - WHERE rn = 1
359 - ORDER BY latest_received_at DESC
360 - LIMIT ? OFFSET ?",
361 - );
362 -
363 - let mut summary_binds: Vec<rusqlite::types::Value> = filter_binds
364 - .iter()
365 - .map(|s| rusqlite::types::Value::from(s.clone()))
366 - .collect();
367 - summary_binds.push(limit_val.into());
368 - summary_binds.push(offset_val.into());
369 -
370 - let summaries = query_all(
371 - &conn,
372 - &summary_sql,
373 - params_from_iter(summary_binds),
374 - |row| {
375 - Ok(ThreadSummary {
376 - thread_key: row.get("thread_key")?,
377 - latest_received_at: row.get("latest_received_at")?,
378 - thread_count: row.get("thread_count")?,
379 - unread_count: row.get("unread_count")?,
380 - latest_email_id: row.get("latest_email_id")?,
381 - })
382 - },
383 - )?;
384 -
385 - if summaries.is_empty() {
386 - return Ok((vec![], total));
387 - }
388 -
389 - // Query 3: Fetch full emails for the page's most-recent-email IDs
390 - let email_ids: Vec<String> = summaries
391 - .iter()
392 - .map(|s| s.latest_email_id.clone())
393 - .collect();
394 - let placeholders = bind_placeholders(email_ids.len());
395 - let emails_sql = format!(
396 - "SELECT {EMAIL_SELECT_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.id IN ({placeholders}) AND e.user_id = ?"
397 - );
398 -
399 - let mut email_binds: Vec<String> = Vec::with_capacity(email_ids.len() + 2);
400 - email_binds.push(uid.clone());
401 - email_binds.extend(email_ids.iter().cloned());
402 - email_binds.push(uid.clone());
403 -
404 - let rows = query_all(
405 - &conn,
406 - &emails_sql,
407 - params_from_iter(email_binds),
408 - EmailRow::from_row,
409 - )?;
410 -
411 - let email_map: HashMap<String, Email> = rows
412 - .into_iter()
413 - .filter_map(|row| {
414 - let id_str = row.id.clone();
415 - Email::try_from(row).ok().map(|e| (id_str, e))
416 - })
417 - .collect();
418 -
419 - // Assemble threads in summary order
420 - let threads: Vec<EmailThread> = summaries
421 - .into_iter()
422 - .filter_map(|s| {
423 - let email = email_map.get(&s.latest_email_id)?.clone();
424 - Some(EmailThread {
425 - thread_id: s.thread_key,
426 - most_recent_email: email,
427 - thread_count: s.thread_count as usize,
428 - has_unread: s.unread_count > 0,
429 - })
430 - })
431 - .collect();
432 -
433 - Ok((threads, total))
434 - }
435 -
436 - #[tracing::instrument(skip_all)]
437 - fn list_by_project(&self, user_id: UserId, project_id: ProjectId) -> Result<Vec<Email>> {
438 - let conn = self.db.conn()?;
439 - // Body-less + capped: the project dashboard renders only subject/from/date
440 - // and opens the reader (which re-fetches the full body via `get_by_id`) on
441 - // click, so this must not materialize every body into RAM (Perf S3, same
442 - // rule as `list_metadata`). `EMAIL_LIST_COLUMNS` forces `body_truncated=1`.
443 - let query = format!(
444 - "SELECT {EMAIL_LIST_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.project_id = ? ORDER BY e.received_at DESC LIMIT {EMAIL_LIST_CAP}"
445 - );
446 - let rows = query_all(
447 - &conn,
448 - &query,
449 - params![
450 - user_id.to_string(),
451 - user_id.to_string(),
452 - project_id.to_string()
453 - ],
454 - EmailRow::from_row,
455 - )?;
456 - rows.into_iter().map(Email::try_from).collect()
457 - }
458 -
459 - #[tracing::instrument(skip_all)]
460 - fn list_by_addresses(&self, user_id: UserId, addresses: &[&str]) -> Result<Vec<Email>> {
461 - if addresses.is_empty() {
462 - return Ok(Vec::new());
463 - }
464 - let placeholders = bind_placeholders(addresses.len());
465 - let query = format!(
466 - "SELECT {EMAIL_SELECT_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND (LOWER(e.from_address) IN ({placeholders}) OR LOWER(e.to_address) IN ({placeholders})) ORDER BY e.received_at DESC LIMIT 200"
467 - );
468 - let mut binds: Vec<String> = Vec::with_capacity(addresses.len() * 2 + 2);
469 - binds.push(user_id.to_string());
470 - binds.push(user_id.to_string());
471 - // Bind addresses twice (once for from_address IN, once for to_address IN)
472 - for _ in 0..2 {
473 - binds.extend(addresses.iter().map(|a| a.to_lowercase()));
474 - }
475 - let conn = self.db.conn()?;
476 - let rows = query_all(&conn, &query, params_from_iter(binds), EmailRow::from_row)?;
477 - rows.into_iter().map(Email::try_from).collect()
478 - }
479 -
480 - #[tracing::instrument(skip_all)]
481 - fn list_unlinked(&self, user_id: UserId) -> Result<Vec<Email>> {
482 - let conn = self.db.conn()?;
483 - // Body-less + capped: the sole caller is the "link email to project" picker,
484 - // which shows only subject/from. No body needed, and an unbounded mailbox
485 - // must not load every body into RAM (Perf S3). `EMAIL_LIST_COLUMNS` forces
486 - // `body_truncated=1` so any later reader re-fetches the full body.
487 - let query = format!(
488 - "SELECT {EMAIL_LIST_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.project_id IS NULL AND e.is_archived = 0 ORDER BY e.received_at DESC LIMIT {EMAIL_LIST_CAP}"
489 - );
490 - let rows = query_all(
491 - &conn,
492 - &query,
493 - params![user_id.to_string(), user_id.to_string()],
494 - EmailRow::from_row,
495 - )?;
496 - rows.into_iter().map(Email::try_from).collect()
497 - }
498 -
499 - #[tracing::instrument(skip_all)]
500 - fn get_by_id(&self, id: EmailId, user_id: UserId) -> Result<Option<Email>> {
Lines truncated
@@ -1,0 +1,182 @@
1 + //! Email writes that create or remove rows: plain create, backup restore, the
2 + //! two IMAP-tracking inserts, and delete.
3 +
4 + use chrono::Utc;
5 + use goingson_core::{
6 + CoreError, DbValue, Email, EmailId, NewEmail, NewEmailWithTracking, Result, UserId,
7 + };
8 + use rusqlite::{Connection, params};
9 +
10 + use crate::utils::{execute, format_datetime, format_datetime_opt};
11 +
12 + use super::query;
13 +
14 + /// Insert a hand-composed email and return it as stored.
15 + pub(super) fn create(conn: &Connection, user_id: UserId, email: &NewEmail) -> Result<Email> {
16 + let id = EmailId::new();
17 + let received_at = format_datetime(&email.received_at.unwrap_or_else(Utc::now));
18 + execute(
19 + conn,
20 + "INSERT INTO emails (id, user_id, project_id, from_address, to_address, subject, body, is_read, received_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
21 + params![
22 + id.to_string(),
23 + user_id.to_string(),
24 + email.project_id.as_ref().map(ToString::to_string),
25 + &email.from_address,
26 + &email.to_address,
27 + &email.subject,
28 + &email.body,
29 + i32::from(email.is_read),
30 + &received_at
31 + ],
32 + )?;
33 + query::get_by_id(conn, id, user_id)?
34 + .ok_or_else(|| CoreError::internal("Failed to retrieve created email"))
35 + }
36 +
37 + /// Re-insert an email from a backup, preserving its original id.
38 + pub(super) fn restore(conn: &Connection, user_id: UserId, email: &Email) -> Result<()> {
39 + // Durable content fields are round-tripped; account-linked and
40 + // sync-transient state (email_account_id, imap_uid, source_folder,
41 + // attachment_meta, draft_account_id) is intentionally omitted, those
42 + // FK into email_accounts (not part of a backup) or are re-derived on the
43 + // next IMAP sync. Preserving the original id + message_id makes a second
44 + // restore a no-op.
45 + let labels_json = serde_json::to_string(&email.labels).unwrap_or_else(|_| "[]".to_string());
46 + execute(
47 + conn,
48 + "INSERT OR IGNORE INTO emails (id, user_id, project_id, from_address, to_address, subject, body, body_format, html_body, is_read, is_archived, received_at, message_id, in_reply_to, thread_id, is_outgoing, labels, is_draft, cc_address, bcc_address, snoozed_until, waiting_for_response, waiting_since, expected_response_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
49 + params![
50 + email.id.to_string(),
51 + user_id.to_string(),
52 + email.project_id.map(|p| p.to_string()),
53 + &email.from,
54 + &email.to,
55 + &email.subject,
56 + &email.body,
57 + email.body_format.db_value(),
58 + &email.html_body,
59 + i32::from(email.is_read),
60 + i32::from(email.is_archived),
61 + format_datetime(&email.received_at),
62 + &email.message_id,
63 + &email.in_reply_to,
64 + &email.thread_id,
65 + i32::from(email.is_outgoing),
66 + &labels_json,
67 + i32::from(email.is_draft),
68 + &email.cc_address,
69 + &email.bcc_address,
70 + format_datetime_opt(email.snoozed_until),
71 + i32::from(email.waiting_for_response),
72 + format_datetime_opt(email.waiting_since),
73 + format_datetime_opt(email.expected_response_date)
74 + ],
75 + )?;
76 + Ok(())
77 + }
78 +
79 + /// Insert one synced message, keyed by a deterministic id from its message-id.
80 + pub(super) fn create_with_tracking(
81 + conn: &Connection,
82 + user_id: UserId,
83 + email: &NewEmailWithTracking,
84 + ) -> Result<Email> {
85 + let id = goingson_core::deterministic_email_id(email.message_id.as_deref());
86 + let received_at = format_datetime(&email.received_at.unwrap_or_else(Utc::now));
87 + execute(
88 + conn,
89 + "INSERT INTO emails (id, user_id, project_id, from_address, to_address, subject, body, body_format, html_body, is_read, is_archived, received_at, message_id, in_reply_to, thread_id, email_account_id, is_outgoing, imap_uid, source_folder, attachment_meta, body_truncated, jmap_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
90 + params![
91 + id.to_string(),
92 + user_id.to_string(),
93 + email.project_id.as_ref().map(ToString::to_string),
94 + &email.from_address,
95 + &email.to_address,
96 + &email.subject,
97 + &email.body,
98 + email.body_format.db_value(),
99 + &email.html_body,
100 + i32::from(email.is_read),
101 + i32::from(email.is_archived),
102 + &received_at,
103 + &email.message_id,
104 + &email.in_reply_to,
105 + &email.thread_id,
106 + email.email_account_id.as_ref().map(ToString::to_string),
107 + i32::from(email.is_outgoing),
108 + email.imap_uid,
109 + &email.source_folder,
110 + &email.attachment_meta,
111 + i32::from(email.body_truncated),
112 + &email.jmap_id
113 + ],
114 + )?;
115 + query::get_by_id(conn, id, user_id)?
116 + .ok_or_else(|| CoreError::internal("Failed to retrieve created email"))
117 + }
118 +
119 + /// Insert a batch of synced messages in one transaction, skipping duplicates.
120 + pub(super) fn create_with_tracking_batch(
121 + conn: &mut Connection,
122 + user_id: UserId,
123 + emails: Vec<NewEmailWithTracking>,
124 + ) -> Result<usize> {
125 + if emails.is_empty() {
126 + return Ok(0);
127 + }
128 +
129 + let mut count = 0usize;
130 + let uid = user_id.to_string();
131 +
132 + let tx = conn.transaction().map_err(CoreError::database)?;
133 +
134 + for email in emails {
135 + let id = goingson_core::deterministic_email_id(email.message_id.as_deref());
136 + let received_at = format_datetime(&email.received_at.unwrap_or_else(Utc::now));
137 + let result = execute(
138 + &tx,
139 + "INSERT OR IGNORE INTO emails (id, user_id, project_id, from_address, to_address, subject, body, body_format, html_body, is_read, is_archived, received_at, message_id, in_reply_to, thread_id, email_account_id, is_outgoing, imap_uid, source_folder, attachment_meta, body_truncated, jmap_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
140 + params![
141 + id.to_string(),
142 + &uid,
143 + email.project_id.map(|p| p.to_string()),
144 + &email.from_address,
145 + &email.to_address,
146 + &email.subject,
147 + &email.body,
148 + email.body_format.db_value(),
149 + &email.html_body,
150 + i32::from(email.is_read),
151 + i32::from(email.is_archived),
152 + &received_at,
153 + &email.message_id,
154 + &email.in_reply_to,
155 + &email.thread_id,
156 + email.email_account_id.map(|a| a.to_string()),
157 + i32::from(email.is_outgoing),
158 + email.imap_uid,
159 + &email.source_folder,
160 + &email.attachment_meta,
161 + i32::from(email.body_truncated),
162 + &email.jmap_id
163 + ],
164 + )?;
165 + if result > 0 {
166 + count += 1;
167 + }
168 + }
169 +
170 + tx.commit().map_err(CoreError::database)?;
171 + Ok(count)
172 + }
173 +
174 + /// Delete one email. Returns whether a row was removed.
175 + pub(super) fn delete(conn: &Connection, id: EmailId, user_id: UserId) -> Result<bool> {
176 + let result = execute(
177 + conn,
178 + "DELETE FROM emails WHERE id = ? AND user_id = ?",
179 + params![id.to_string(), user_id.to_string()],
180 + )?;
181 + Ok(result > 0)
182 + }
@@ -1,0 +1,187 @@
1 + //! The compose and send state machine: drafts, the outbox queue, the due set,
2 + //! and send-failure bookkeeping.
3 +
4 + use chrono::{DateTime, Utc};
5 + use goingson_core::{CoreError, Email, EmailAccountId, EmailId, Result, UserId};
6 + use rusqlite::{Connection, params};
7 +
8 + use crate::utils::{execute, format_datetime, format_datetime_now, format_datetime_opt, query_all};
9 +
10 + use super::query;
11 + use super::row::{EMAIL_LIST_COLUMNS, EMAIL_SELECT_COLUMNS, EmailRow};
12 +
13 + /// Every draft, newest first.
14 + pub(super) fn list_drafts(conn: &Connection, user_id: UserId) -> Result<Vec<Email>> {
15 + let sql = format!(
16 + "SELECT {EMAIL_SELECT_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.is_draft = 1 ORDER BY e.received_at DESC"
17 + );
18 + let rows = query_all(
19 + conn,
20 + &sql,
21 + params![user_id.to_string(), user_id.to_string()],
22 + EmailRow::from_row,
23 + )?;
24 + rows.into_iter().map(Email::try_from).collect()
25 + }
26 +
27 + /// Upsert a draft by id and return it as stored.
28 + #[allow(clippy::too_many_arguments)]
29 + pub(super) fn save_draft(
30 + conn: &Connection,
31 + id: EmailId,
32 + user_id: UserId,
33 + from: &str,
34 + to: &str,
35 + cc: Option<&str>,
36 + bcc: Option<&str>,
37 + subject: &str,
38 + body: &str,
39 + account_id: Option<EmailAccountId>,
40 + in_reply_to: Option<&str>,
41 + thread_id: Option<&str>,
42 + ) -> Result<Email> {
43 + let now = format_datetime_now();
44 + let account_id_str = account_id.map(|a: EmailAccountId| a.to_string());
45 +
46 + // Upsert: update if exists, insert if not
47 + execute(
48 + conn,
49 + r"
50 + INSERT INTO emails (id, user_id, from_address, to_address, cc_address, bcc_address, subject, body,
51 + is_read, is_archived, is_draft, is_outgoing, received_at, draft_account_id, in_reply_to, thread_id)
52 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 1, 1, ?, ?, ?, ?)
53 + ON CONFLICT(id) DO UPDATE SET
54 + from_address = excluded.from_address,
55 + to_address = excluded.to_address,
56 + cc_address = excluded.cc_address,
57 + bcc_address = excluded.bcc_address,
58 + subject = excluded.subject,
59 + body = excluded.body,
60 + received_at = excluded.received_at,
61 + draft_account_id = excluded.draft_account_id,
62 + in_reply_to = excluded.in_reply_to,
63 + thread_id = excluded.thread_id
64 + ",
65 + params![
66 + id.to_string(),
67 + user_id.to_string(),
68 + from,
69 + to,
70 + cc,
71 + bcc,
72 + subject,
73 + body,
74 + &now,
75 + &account_id_str,
76 + in_reply_to,
77 + thread_id
78 + ],
79 + )?;
80 +
81 + query::get_by_id(conn, id, user_id)?
82 + .ok_or_else(|| CoreError::internal("Failed to retrieve saved draft"))
83 + }
84 +
85 + /// Queue a draft for sending, optionally not before `send_after`.
86 + pub(super) fn queue_draft(
87 + conn: &Connection,
88 + id: EmailId,
89 + user_id: UserId,
90 + send_after: Option<DateTime<Utc>>,
91 + ) -> Result<Option<Email>> {
92 + // `is_draft = 1` in the predicate rather than checked first: queueing a
93 + // received message is refused by the write not matching, so there is no
94 + // window between the check and the update.
95 + let changed = execute(
96 + conn,
97 + "UPDATE emails SET queued_at = ?, send_after = ?, send_attempts = 0, send_error = NULL
98 + WHERE id = ? AND user_id = ? AND is_draft = 1",
99 + params![
100 + format_datetime_now(),
101 + format_datetime_opt(send_after),
102 + id.to_string(),
103 + user_id.to_string(),
104 + ],
105 + )?;
106 + if changed == 0 {
107 + return Ok(None);
108 + }
109 + query::get_by_id(conn, id, user_id)
110 + }
111 +
112 + /// Take a queued draft back out of the outbox.
113 + pub(super) fn unqueue_draft(
114 + conn: &Connection,
115 + id: EmailId,
116 + user_id: UserId,
117 + ) -> Result<Option<Email>> {
118 + let changed = execute(
119 + conn,
120 + "UPDATE emails SET queued_at = NULL, send_after = NULL, send_attempts = 0, send_error = NULL
121 + WHERE id = ? AND user_id = ? AND queued_at IS NOT NULL",
122 + params![id.to_string(), user_id.to_string()],
123 + )?;
124 + if changed == 0 {
125 + return Ok(None);
126 + }
127 + query::get_by_id(conn, id, user_id)
128 + }
129 +
130 + /// Queued drafts, oldest queued first. Bodies are blanked.
131 + pub(super) fn list_outbox(conn: &Connection, user_id: UserId) -> Result<Vec<Email>> {
132 + let sql = format!(
133 + "SELECT {EMAIL_LIST_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ?
134 + WHERE e.user_id = ? AND e.is_draft = 1 AND e.queued_at IS NOT NULL
135 + ORDER BY e.queued_at ASC"
136 + );
137 + let rows = query_all(
138 + conn,
139 + &sql,
140 + params![user_id.to_string(), user_id.to_string()],
141 + EmailRow::from_row,
142 + )?;
143 + rows.into_iter().map(Email::try_from).collect()
144 + }
145 +
146 + /// Queued drafts whose send time has arrived, bodies included.
147 + pub(super) fn list_due(
148 + conn: &Connection,
149 + user_id: UserId,
150 + now: DateTime<Utc>,
151 + ) -> Result<Vec<Email>> {
152 + // The body is wanted here, unlike the outbox list: this is the set that
153 + // is about to be sent, and a blanked body would send an empty message.
154 + let sql = format!(
155 + "SELECT {EMAIL_SELECT_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ?
156 + WHERE e.user_id = ? AND e.is_draft = 1 AND e.queued_at IS NOT NULL
157 + AND (e.send_after IS NULL OR e.send_after <= ?)
158 + ORDER BY e.queued_at ASC"
159 + );
160 + let rows = query_all(
161 + conn,
162 + &sql,
163 + params![
164 + user_id.to_string(),
165 + user_id.to_string(),
166 + format_datetime(&now)
167 + ],
168 + EmailRow::from_row,
169 + )?;
170 + rows.into_iter().map(Email::try_from).collect()
171 + }
172 +
173 + /// Count a failed send attempt and record its error.
174 + pub(super) fn record_send_failure(
175 + conn: &Connection,
176 + id: EmailId,
177 + user_id: UserId,
178 + error: &str,
179 + ) -> Result<()> {
180 + execute(
181 + conn,
182 + "UPDATE emails SET send_attempts = send_attempts + 1, send_error = ?
183 + WHERE id = ? AND user_id = ?",
184 + params![error, id.to_string(), user_id.to_string()],
185 + )?;
186 + Ok(())
187 + }
@@ -1,0 +1,138 @@
1 + //! Single-column flag updates on an email: read/unread, archive, folder, body,
2 + //! project link, labels, plus the unread count.
3 +
4 + use goingson_core::{CoreError, Email, EmailId, ProjectId, Result, UserId};
5 + use rusqlite::{Connection, params};
6 +
7 + use crate::utils::execute;
8 +
9 + use super::query;
10 +
11 + /// Mark one email read. Returns whether a row changed.
12 + pub(super) fn mark_read(conn: &Connection, id: EmailId, user_id: UserId) -> Result<bool> {
13 + let result = execute(
14 + conn,
15 + "UPDATE emails SET is_read = 1 WHERE id = ? AND user_id = ?",
16 + params![id.to_string(), user_id.to_string()],
17 + )?;
18 + Ok(result > 0)
19 + }
20 +
21 + /// Mark one email unread. Returns whether a row changed.
22 + pub(super) fn mark_unread(conn: &Connection, id: EmailId, user_id: UserId) -> Result<bool> {
23 + let result = execute(
24 + conn,
25 + "UPDATE emails SET is_read = 0 WHERE id = ? AND user_id = ?",
26 + params![id.to_string(), user_id.to_string()],
27 + )?;
28 + Ok(result > 0)
29 + }
30 +
31 + /// Archive one email. Returns whether a row changed.
32 + pub(super) fn archive(conn: &Connection, id: EmailId, user_id: UserId) -> Result<bool> {
33 + let result = execute(
34 + conn,
35 + "UPDATE emails SET is_archived = 1 WHERE id = ? AND user_id = ?",
36 + params![id.to_string(), user_id.to_string()],
37 + )?;
38 + Ok(result > 0)
39 + }
40 +
41 + /// Unarchive one email. Returns whether a row changed.
42 + pub(super) fn unarchive(conn: &Connection, id: EmailId, user_id: UserId) -> Result<bool> {
43 + let result = execute(
44 + conn,
45 + "UPDATE emails SET is_archived = 0 WHERE id = ? AND user_id = ?",
46 + params![id.to_string(), user_id.to_string()],
47 + )?;
48 + Ok(result > 0)
49 + }
50 +
51 + /// Repoint one email at a different IMAP folder.
52 + pub(super) fn update_source_folder(
53 + conn: &Connection,
54 + id: EmailId,
55 + user_id: UserId,
56 + new_folder: &str,
57 + ) -> Result<bool> {
58 + let result = execute(
59 + conn,
60 + "UPDATE emails SET source_folder = ? WHERE id = ? AND user_id = ?",
61 + params![new_folder, id.to_string(), user_id.to_string()],
62 + )?;
63 + Ok(result > 0)
64 + }
65 +
66 + /// Store a fetched full body and clear the truncated flag.
67 + pub(super) fn set_full_body(
68 + conn: &Connection,
69 + id: EmailId,
70 + user_id: UserId,
71 + body: &str,
72 + ) -> Result<()> {
73 + execute(
74 + conn,
75 + "UPDATE emails SET body = ?, body_truncated = 0 WHERE id = ? AND user_id = ?",
76 + params![body, id.to_string(), user_id.to_string()],
77 + )?;
78 + Ok(())
79 + }
80 +
81 + /// Mark every unread email read. Returns how many changed.
82 + pub(super) fn mark_all_read(conn: &Connection, user_id: UserId) -> Result<u64> {
83 + let result = execute(
84 + conn,
85 + "UPDATE emails SET is_read = 1 WHERE user_id = ? AND is_read = 0",
86 + params![user_id.to_string()],
87 + )?;
88 + Ok(result as u64)
89 + }
90 +
91 + /// Link one email to a project, or clear the link with `None`.
92 + pub(super) fn link_to_project(
93 + conn: &Connection,
94 + id: EmailId,
95 + user_id: UserId,
96 + project_id: Option<ProjectId>,
97 + ) -> Result<bool> {
98 + let result = execute(
99 + conn,
100 + "UPDATE emails SET project_id = ? WHERE id = ? AND user_id = ?",
101 + params![
102 + project_id.map(|p| p.to_string()),
103 + id.to_string(),
104 + user_id.to_string()
105 + ],
106 + )?;
107 + Ok(result > 0)
108 + }
109 +
110 + /// How many unread emails the user has.
111 + pub(super) fn count_unread(conn: &Connection, user_id: UserId) -> Result<i64> {
112 + conn.query_row(
113 + "SELECT COUNT(*) FROM emails WHERE user_id = ? AND is_read = 0",
114 + params![user_id.to_string()],
115 + |row| row.get(0),
116 + )
117 + .map_err(CoreError::database)
118 + }
119 +
120 + /// Replace one email's label set, returning the updated email.
121 + pub(super) fn update_labels(
122 + conn: &Connection,
123 + id: EmailId,
124 + user_id: UserId,
125 + labels: &[String],
126 + ) -> Result<Option<Email>> {
127 + let labels_json = serde_json::to_string(labels).unwrap_or_else(|_| "[]".to_string());
128 + let result = execute(
129 + conn,
130 + "UPDATE emails SET labels = ? WHERE id = ? AND user_id = ?",
131 + params![&labels_json, id.to_string(), user_id.to_string()],
132 + )?;
133 + if result > 0 {
134 + query::get_by_id(conn, id, user_id)
135 + } else {
136 + Ok(None)
137 + }
138 + }
@@ -1,0 +1,377 @@
1 + //! SQLite implementation of the EmailRepository.
2 + //!
3 + //! Manages email messages with support for:
4 + //! - IMAP synchronization tracking (message_id, imap_uid)
5 + //! - Threading via in_reply_to and thread_id
6 + //! - Read/archived status
7 + //! - Project associations
8 + //! - Snoozing and waiting-for-response tracking
9 + //!
10 + //! A trait impl cannot be split across files, so this module keeps the struct
11 + //! and the single `impl EmailRepository` block, and every method checks out a
12 + //! connection and hands it to a free function in one of the submodules below.
13 +
14 + mod crud;
15 + mod draft;
16 + mod flags;
17 + mod query;
18 + mod row;
19 + mod state;
20 + mod sync;
21 + mod thread;
22 +
23 + use std::collections::HashSet;
24 +
25 + use chrono::{DateTime, Utc};
26 + use goingson_core::{
27 + Email, EmailAccountId, EmailId, EmailRepository, EmailThread, NewEmail, NewEmailWithTracking,
28 + ProjectId, Result, UserId,
29 + };
30 +
31 + use crate::Db;
32 +
33 + /// SQLite-backed implementation of [`EmailRepository`].
34 + ///
35 + /// Manages email messages with threading support, snoozing, and
36 + /// waiting-for-response tracking. Integrates with IMAP sync via message_id.
37 + pub struct SqliteEmailRepository {
38 + db: Db,
39 + }
40 +
41 + impl SqliteEmailRepository {
42 + /// Creates a new repository instance with the given connection pool.
43 + #[tracing::instrument(skip_all)]
44 + pub fn new(db: Db) -> Self {
45 + Self { db }
46 + }
47 + }
48 +
49 + impl EmailRepository for SqliteEmailRepository {
50 + #[tracing::instrument(skip_all)]
51 + fn list_all_for_backup(&self, user_id: UserId) -> Result<Vec<Email>> {
52 + let conn = self.db.conn()?;
53 + query::list_all_for_backup(&conn, user_id)
54 + }
55 +
56 + fn list_all(&self, user_id: UserId, include_archived: bool) -> Result<Vec<Email>> {
57 + let conn = self.db.conn()?;
58 + query::list_all(&conn, user_id, include_archived)
59 + }
60 +
61 + #[tracing::instrument(skip_all)]
62 + fn list_metadata(&self, user_id: UserId, include_archived: bool) -> Result<Vec<Email>> {
63 + let conn = self.db.conn()?;
64 + query::list_metadata(&conn, user_id, include_archived)
65 + }
66 +
67 + #[tracing::instrument(skip_all)]
68 + fn list_threaded(
69 + &self,
70 + user_id: UserId,
71 + include_archived: bool,
72 + offset: Option<i64>,
73 + limit: Option<i64>,
74 + folder: Option<&str>,
75 + label: Option<&str>,
76 + ) -> Result<(Vec<EmailThread>, i64)> {
77 + let conn = self.db.conn()?;
78 + thread::list_threaded(
79 + &conn,
80 + user_id,
81 + include_archived,
82 + offset,
83 + limit,
84 + folder,
85 + label,
86 + )
87 + }
88 +
89 + #[tracing::instrument(skip_all)]
90 + fn list_by_project(&self, user_id: UserId, project_id: ProjectId) -> Result<Vec<Email>> {
91 + let conn = self.db.conn()?;
92 + query::list_by_project(&conn, user_id, project_id)
93 + }
94 +
95 + #[tracing::instrument(skip_all)]
96 + fn list_by_addresses(&self, user_id: UserId, addresses: &[&str]) -> Result<Vec<Email>> {
97 + let conn = self.db.conn()?;
98 + query::list_by_addresses(&conn, user_id, addresses)
99 + }
100 +
101 + #[tracing::instrument(skip_all)]
102 + fn list_unlinked(&self, user_id: UserId) -> Result<Vec<Email>> {
103 + let conn = self.db.conn()?;
104 + query::list_unlinked(&conn, user_id)
105 + }
106 +
107 + #[tracing::instrument(skip_all)]
108 + fn get_by_id(&self, id: EmailId, user_id: UserId) -> Result<Option<Email>> {
109 + let conn = self.db.conn()?;
110 + query::get_by_id(&conn, id, user_id)
111 + }
112 +
113 + #[tracing::instrument(skip_all)]
114 + fn create(&self, user_id: UserId, email: NewEmail) -> Result<Email> {
115 + let conn = self.db.conn()?;
116 + crud::create(&conn, user_id, &email)
117 + }
118 +
119 + #[tracing::instrument(skip_all)]
120 + fn restore(&self, user_id: UserId, email: &Email) -> Result<()> {
121 + let conn = self.db.conn()?;
122 + crud::restore(&conn, user_id, email)
123 + }
124 +
125 + #[tracing::instrument(skip_all)]
126 + fn create_with_tracking(&self, user_id: UserId, email: NewEmailWithTracking) -> Result<Email> {
127 + let conn = self.db.conn()?;
128 + crud::create_with_tracking(&conn, user_id, &email)
129 + }
130 +
131 + #[tracing::instrument(skip_all)]
132 + fn create_with_tracking_batch(
133 + &self,
134 + user_id: UserId,
135 + emails: Vec<NewEmailWithTracking>,
136 + ) -> Result<usize> {
137 + let mut conn = self.db.conn()?;
138 + crud::create_with_tracking_batch(&mut conn, user_id, emails)
139 + }
140 +
141 + #[tracing::instrument(skip_all)]
142 + fn delete(&self, id: EmailId, user_id: UserId) -> Result<bool> {
143 + let conn = self.db.conn()?;
144 + crud::delete(&conn, id, user_id)
145 + }
146 +
147 + #[tracing::instrument(skip_all)]
148 + fn mark_read(&self, id: EmailId, user_id: UserId) -> Result<bool> {
149 + let conn = self.db.conn()?;
150 + flags::mark_read(&conn, id, user_id)
151 + }
152 +
153 + #[tracing::instrument(skip_all)]
154 + fn mark_unread(&self, id: EmailId, user_id: UserId) -> Result<bool> {
155 + let conn = self.db.conn()?;
156 + flags::mark_unread(&conn, id, user_id)
157 + }
158 +
159 + #[tracing::instrument(skip_all)]
160 + fn archive(&self, id: EmailId, user_id: UserId) -> Result<bool> {
161 + let conn = self.db.conn()?;
162 + flags::archive(&conn, id, user_id)
163 + }
164 +
165 + #[tracing::instrument(skip_all)]
166 + fn unarchive(&self, id: EmailId, user_id: UserId) -> Result<bool> {
167 + let conn = self.db.conn()?;
168 + flags::unarchive(&conn, id, user_id)
169 + }
170 +
171 + #[tracing::instrument(skip_all)]
172 + fn update_source_folder(&self, id: EmailId, user_id: UserId, new_folder: &str) -> Result<bool> {
173 + let conn = self.db.conn()?;
174 + flags::update_source_folder(&conn, id, user_id, new_folder)
175 + }
176 +
177 + #[tracing::instrument(skip_all)]
178 + fn set_full_body(&self, id: EmailId, user_id: UserId, body: &str) -> Result<()> {
179 + let conn = self.db.conn()?;
180 + flags::set_full_body(&conn, id, user_id, body)
181 + }
182 +
183 + #[tracing::instrument(skip_all)]
184 + fn mark_all_read(&self, user_id: UserId) -> Result<u64> {
185 + let conn = self.db.conn()?;
186 + flags::mark_all_read(&conn, user_id)
187 + }
188 +
189 + #[tracing::instrument(skip_all)]
190 + fn link_to_project(
191 + &self,
192 + id: EmailId,
193 + user_id: UserId,
194 + project_id: Option<ProjectId>,
195 + ) -> Result<bool> {
196 + let conn = self.db.conn()?;
197 + flags::link_to_project(&conn, id, user_id, project_id)
198 + }
199 +
200 + #[tracing::instrument(skip_all)]
201 + fn count_unread(&self, user_id: UserId) -> Result<i64> {
202 + let conn = self.db.conn()?;
203 + flags::count_unread(&conn, user_id)
204 + }
205 +
206 + #[tracing::instrument(skip_all)]
207 + fn exists_by_message_id(&self, user_id: UserId, message_id: &str) -> Result<bool> {
208 + let conn = self.db.conn()?;
209 + sync::exists_by_message_id(&conn, user_id, message_id)
210 + }
211 +
212 + #[tracing::instrument(skip_all)]
213 + fn exists_by_message_ids(
214 + &self,
215 + user_id: UserId,
216 + message_ids: &[&str],
217 + ) -> Result<HashSet<String>> {
218 + let conn = self.db.conn()?;
219 + sync::exists_by_message_ids(&conn, user_id, message_ids)
220 + }
221 +
222 + #[tracing::instrument(skip_all)]
223 + fn exists_as_senders(&self, user_id: UserId, addresses: &[&str]) -> Result<HashSet<String>> {
224 + let conn = self.db.conn()?;
225 + sync::exists_as_senders(&conn, user_id, addresses)
226 + }
227 +
228 + #[tracing::instrument(skip_all)]
229 + fn snooze(&self, id: EmailId, user_id: UserId, until: DateTime<Utc>) -> Result<Option<Email>> {
230 + let conn = self.db.conn()?;
231 + state::snooze(&conn, id, user_id, until)
232 + }
233 +
234 + #[tracing::instrument(skip_all)]
235 + fn unsnooze(&self, id: EmailId, user_id: UserId) -> Result<Option<Email>> {
236 + let conn = self.db.conn()?;
237 + state::unsnooze(&conn, id, user_id)
238 + }
239 +
240 + #[tracing::instrument(skip_all)]
241 + fn list_snoozed(&self, user_id: UserId) -> Result<Vec<Email>> {
242 + let conn = self.db.conn()?;
243 + state::list_snoozed(&conn, user_id)
244 + }
245 +
246 + #[tracing::instrument(skip_all)]
247 + fn mark_waiting(
248 + &self,
249 + id: EmailId,
250 + user_id: UserId,
251 + expected_response: Option<DateTime<Utc>>,
252 + ) -> Result<Option<Email>> {
253 + let conn = self.db.conn()?;
254 + state::mark_waiting(&conn, id, user_id, expected_response)
255 + }
256 +
257 + #[tracing::instrument(skip_all)]
258 + fn clear_waiting(&self, id: EmailId, user_id: UserId) -> Result<Option<Email>> {
259 + let conn = self.db.conn()?;
260 + state::clear_waiting(&conn, id, user_id)
261 + }
262 +
263 + #[tracing::instrument(skip_all)]
264 + fn list_waiting(&self, user_id: UserId) -> Result<Vec<Email>> {
265 + let conn = self.db.conn()?;
266 + state::list_waiting(&conn, user_id)
267 + }
268 +
269 + #[tracing::instrument(skip_all)]
270 + fn list_by_thread(&self, user_id: UserId, thread_id: &str) -> Result<Vec<Email>> {
271 + let conn = self.db.conn()?;
272 + thread::list_by_thread(&conn, user_id, thread_id)
273 + }
274 +
275 + #[tracing::instrument(skip_all)]
276 + fn list_drafts(&self, user_id: UserId) -> Result<Vec<Email>> {
277 + let conn = self.db.conn()?;
278 + draft::list_drafts(&conn, user_id)
279 + }
280 +
281 + #[tracing::instrument(skip_all)]
282 + fn save_draft(
283 + &self,
284 + id: EmailId,
285 + user_id: UserId,
286 + from: &str,
287 + to: &str,
288 + cc: Option<&str>,
289 + bcc: Option<&str>,
290 + subject: &str,
291 + body: &str,
292 + account_id: Option<EmailAccountId>,
293 + in_reply_to: Option<&str>,
294 + _references: Option<&str>,
295 + thread_id: Option<&str>,
296 + ) -> Result<Email> {
297 + let conn = self.db.conn()?;
298 + draft::save_draft(
299 + &conn,
300 + id,
301 + user_id,
302 + from,
303 + to,
304 + cc,
305 + bcc,
306 + subject,
307 + body,
308 + account_id,
309 + in_reply_to,
310 + thread_id,
311 + )
312 + }
313 +
314 + #[tracing::instrument(skip_all)]
315 + fn queue_draft(
316 + &self,
317 + id: EmailId,
318 + user_id: UserId,
319 + send_after: Option<DateTime<Utc>>,
320 + ) -> Result<Option<Email>> {
321 + let conn = self.db.conn()?;
322 + draft::queue_draft(&conn, id, user_id, send_after)
323 + }
324 +
325 + #[tracing::instrument(skip_all)]
326 + fn unqueue_draft(&self, id: EmailId, user_id: UserId) -> Result<Option<Email>> {
327 + let conn = self.db.conn()?;
328 + draft::unqueue_draft(&conn, id, user_id)
329 + }
330 +
331 + #[tracing::instrument(skip_all)]
332 + fn list_outbox(&self, user_id: UserId) -> Result<Vec<Email>> {
333 + let conn = self.db.conn()?;
334 + draft::list_outbox(&conn, user_id)
335 + }
336 +
337 + #[tracing::instrument(skip_all)]
338 + fn list_due(&self, user_id: UserId, now: DateTime<Utc>) -> Result<Vec<Email>> {
339 + let conn = self.db.conn()?;
340 + draft::list_due(&conn, user_id, now)
341 + }
342 +
343 + #[tracing::instrument(skip_all)]
344 + fn record_send_failure(&self, id: EmailId, user_id: UserId, error: &str) -> Result<()> {
345 + let conn = self.db.conn()?;
346 + draft::record_send_failure(&conn, id, user_id, error)
347 + }
348 +
349 + #[tracing::instrument(skip_all)]
350 + fn get_by_message_id(&self, user_id: UserId, message_id: &str) -> Result<Option<Email>> {
351 + let conn = self.db.conn()?;
352 + sync::get_by_message_id(&conn, user_id, message_id)
353 + }
354 +
355 + #[tracing::instrument(skip_all)]
356 + fn update_labels(
357 + &self,
358 + id: EmailId,
359 + user_id: UserId,
360 + labels: &[String],
361 + ) -> Result<Option<Email>> {
362 + let conn = self.db.conn()?;
363 + flags::update_labels(&conn, id, user_id, labels)
364 + }
365 +
366 + #[tracing::instrument(skip_all)]
367 + fn list_folders(&self, user_id: UserId) -> Result<Vec<String>> {
368 + let conn = self.db.conn()?;
369 + sync::list_folders(&conn, user_id)
370 + }
371 +
372 + #[tracing::instrument(skip_all)]
373 + fn list_labels(&self, user_id: UserId) -> Result<Vec<String>> {
374 + let conn = self.db.conn()?;
375 + sync::list_labels(&conn, user_id)
376 + }
377 + }