ids, long now);
+
+ /** Count total cached mails (for LRU capping). */
+ @Query("SELECT COUNT(*) FROM mails")
+ int countAll();
+
+ /** Kill oldest rows by lastAccessEpoch (simple LRU). */
+ @Query("DELETE FROM mails WHERE id IN (" +
+ "SELECT id FROM mails ORDER BY lastAccessEpoch ASC LIMIT :howMany)")
+ void evictOldest(int howMany);
+
+ /** Remove everything (used on logout). */
+ @Query("DELETE FROM mails")
+ void clearAll();
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/caching/entities/LabelEntity.java b/android_app/app/src/main/java/com/asp/android_app/caching/entities/LabelEntity.java
new file mode 100644
index 00000000..1a1063b9
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/caching/entities/LabelEntity.java
@@ -0,0 +1,17 @@
+package com.asp.android_app.caching.entities;
+
+import androidx.room.Entity;
+import androidx.room.PrimaryKey;
+
+/**
+ * Room cache for a Label as returned by backend.
+ * Parent is an Integer (nullable) in the server model,
+ * stored here as Integer-compatible (use -1 for "no parent" if you prefer).
+ */
+@Entity(tableName = "labels")
+public class LabelEntity {
+ @PrimaryKey
+ public int id;
+ public String name;
+ public Integer parent; // null = no parent
+}
diff --git a/android_app/app/src/main/java/com/asp/android_app/caching/entities/MailEntity.java b/android_app/app/src/main/java/com/asp/android_app/caching/entities/MailEntity.java
new file mode 100644
index 00000000..11e67008
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/caching/entities/MailEntity.java
@@ -0,0 +1,63 @@
+package com.asp.android_app.caching.entities;
+
+import androidx.room.Entity;
+import androidx.room.Index;
+import androidx.room.PrimaryKey;
+
+/**
+ * Room cache model for a Mail.
+ * - Denormalized for speed (flags directly on the row).
+ * - We keep a few denormalized sender fields so lists render without joins.
+ * - Recipients & attachment names are stored as JSON strings (simple & fast).
+ *
+ * NOTE: We sort by sentAtEpoch (fallback to createdAtEpoch) to match backend's order.
+ */
+@Entity(tableName = "mails",
+ indices = {
+ @Index(value = {"id"}, unique = true),
+ @Index(value = {"ownerId"}),
+ @Index(value = {"isDraft"}),
+ @Index(value = {"isRead"}),
+ @Index(value = {"isStarred"}),
+ @Index(value = {"isTrashed"}),
+ @Index(value = {"isSpam"}),
+ @Index(value = {"sentAtEpoch"}),
+ @Index(value = {"createdAtEpoch"})
+ })
+public class MailEntity {
+
+ @PrimaryKey
+ public int id; // server mail id
+
+ public int ownerId; // owner per server model
+
+ // Sender (denormalized for quick list rendering)
+ public int fromId;
+ public String fromName;
+ public String fromEmail;
+ public String fromImageUrl;
+
+ // Main content
+ public String subject;
+ public String body;
+
+ // Dates: raw ISO + parsed epoch for sort/filter
+ public String sentAtRaw; // can be "" for drafts
+ public String createdAtRaw;
+ public long sentAtEpoch; // 0 if unknown
+ public long createdAtEpoch; // fallback
+
+ // Flags (we derive inboxes by these)
+ public boolean isDraft;
+ public boolean isRead;
+ public boolean isStarred;
+ public boolean isTrashed;
+ public boolean isSpam;
+
+ // JSON blobs to keep schema simple
+ public String recipientsJson; // List as JSON
+ public String attachmentNamesJson; // List names only
+
+ // Bookkeeping for cache eviction (simple LRU)
+ public long lastAccessEpoch; // updated whenever we read/open
+}
diff --git a/android_app/app/src/main/java/com/asp/android_app/caching/entities/MailLabelCrossRef.java b/android_app/app/src/main/java/com/asp/android_app/caching/entities/MailLabelCrossRef.java
new file mode 100644
index 00000000..0df43d59
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/caching/entities/MailLabelCrossRef.java
@@ -0,0 +1,33 @@
+package com.asp.android_app.caching.entities;
+
+import androidx.room.Entity;
+import androidx.room.ForeignKey;
+import androidx.room.Index;
+
+/**
+ * Many-to-many relation between mails and labels.
+ * We only need mailId & labelId since both sides are unique.
+ */
+@Entity(tableName = "mail_label",
+ primaryKeys = {"mailId", "labelId"},
+ foreignKeys = {
+ @ForeignKey(entity = MailEntity.class,
+ parentColumns = "id", childColumns = "mailId",
+ onDelete = ForeignKey.CASCADE),
+ @ForeignKey(entity = LabelEntity.class,
+ parentColumns = "id", childColumns = "labelId",
+ onDelete = ForeignKey.CASCADE)
+ },
+ indices = {
+ @Index("mailId"),
+ @Index("labelId")
+ })
+public class MailLabelCrossRef {
+ public int mailId;
+ public int labelId;
+
+ public MailLabelCrossRef(int mailId, int labelId) {
+ this.mailId = mailId;
+ this.labelId = labelId;
+ }
+}
diff --git a/android_app/app/src/main/java/com/asp/android_app/caching/entities/UserLite.java b/android_app/app/src/main/java/com/asp/android_app/caching/entities/UserLite.java
new file mode 100644
index 00000000..40b2bc0c
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/caching/entities/UserLite.java
@@ -0,0 +1,19 @@
+package com.asp.android_app.caching.entities;
+
+/**
+ * Lightweight user used inside Room JSON columns (recipients).
+ * This mirrors the subset we need for offline display.
+ */
+public class UserLite {
+ public int id;
+ public String mail;
+ public String fullName;
+ public String imageUrl; // optional
+
+ public UserLite(int id, String mail, String fullName, String imageUrl) {
+ this.id = id;
+ this.mail = mail;
+ this.fullName = fullName;
+ this.imageUrl = imageUrl;
+ }
+}
diff --git a/android_app/app/src/main/java/com/asp/android_app/caching/utils/JsonConverters.java b/android_app/app/src/main/java/com/asp/android_app/caching/utils/JsonConverters.java
new file mode 100644
index 00000000..5426dd3d
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/caching/utils/JsonConverters.java
@@ -0,0 +1,46 @@
+package com.asp.android_app.caching.utils;
+
+import androidx.annotation.Nullable;
+import androidx.room.TypeConverter;
+
+import com.asp.android_app.caching.entities.UserLite;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+
+import java.lang.reflect.Type;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Room TypeConverters for small JSON blobs.
+ * - List for attachment names
+ * - List for recipients
+ * Keeping JSON simple avoids extra tables until we actually need them.
+ */
+public class JsonConverters {
+ private static final Gson gson = new Gson();
+ private static final Type LIST_STRING = new TypeToken>() {}.getType();
+ private static final Type LIST_USERLITE = new TypeToken>() {}.getType();
+
+ @TypeConverter
+ public static String listStringToJson(@Nullable List list) {
+ return gson.toJson(list == null ? Collections.emptyList() : list, LIST_STRING);
+ }
+
+ @TypeConverter
+ public static List jsonToListString(@Nullable String json) {
+ if (json == null || json.isEmpty()) return Collections.emptyList();
+ return gson.fromJson(json, LIST_STRING);
+ }
+
+ @TypeConverter
+ public static String listUserLiteToJson(@Nullable List list) {
+ return gson.toJson(list == null ? Collections.emptyList() : list, LIST_USERLITE);
+ }
+
+ @TypeConverter
+ public static List jsonToListUserLite(@Nullable String json) {
+ if (json == null || json.isEmpty()) return Collections.emptyList();
+ return gson.fromJson(json, LIST_USERLITE);
+ }
+}
diff --git a/android_app/app/src/main/java/com/asp/android_app/caching/utils/LabelMappers.java b/android_app/app/src/main/java/com/asp/android_app/caching/utils/LabelMappers.java
new file mode 100644
index 00000000..ceae2e5a
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/caching/utils/LabelMappers.java
@@ -0,0 +1,29 @@
+package com.asp.android_app.caching.utils;
+
+import com.asp.android_app.caching.entities.LabelEntity;
+import com.asp.android_app.model.Label;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Tiny mapper between network Label and Room LabelEntity.
+ */
+public final class LabelMappers {
+ private LabelMappers(){}
+
+ public static LabelEntity toEntity(Label l) {
+ LabelEntity e = new LabelEntity();
+ e.id = l.getId();
+ e.name = l.getName();
+ e.parent = l.getParent();
+ return e;
+ }
+
+ public static List toEntities(List