├── backend
├── src
│ ├── handlers
│ │ ├── mod.rs
│ │ └── auth.rs
│ ├── middleware
│ │ ├── mod.rs
│ │ └── auth.rs
│ ├── services
│ │ ├── mod.rs
│ │ └── auth.rs
│ ├── models
│ │ ├── mod.rs
│ │ ├── user.rs
│ │ ├── auth_token.rs
│ │ ├── email.rs
│ │ ├── sync_status.rs
│ │ ├── link.rs
│ │ └── tests.rs
│ ├── database.rs
│ └── main.rs
├── .gitignore
├── .env.example
├── Cargo.toml
├── migrations
│ └── 001_initial_schema.sql
└── Cargo.lock
├── frontend
├── src
│ ├── app
│ │ ├── favicon.ico
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ └── page.tsx
│ └── types
│ │ └── index.ts
├── postcss.config.mjs
├── public
│ ├── vercel.svg
│ ├── window.svg
│ ├── file.svg
│ ├── globe.svg
│ └── next.svg
├── next.config.ts
├── eslint.config.mjs
├── .gitignore
├── package.json
├── tsconfig.json
└── README.md
└── .kiro
└── specs
└── gmail-link-queue
├── requirements.md
├── tasks.md
└── design.md
/backend/src/handlers/mod.rs:
--------------------------------------------------------------------------------
1 | pub mod auth;
--------------------------------------------------------------------------------
/backend/src/middleware/mod.rs:
--------------------------------------------------------------------------------
1 | pub mod auth;
--------------------------------------------------------------------------------
/backend/src/services/mod.rs:
--------------------------------------------------------------------------------
1 | pub mod auth;
--------------------------------------------------------------------------------
/backend/.gitignore:
--------------------------------------------------------------------------------
1 | /target
2 | .env
3 | *.db
4 |
--------------------------------------------------------------------------------
/frontend/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fcoury/etomys/master/frontend/src/app/favicon.ico
--------------------------------------------------------------------------------
/frontend/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: ["@tailwindcss/postcss"],
3 | };
4 |
5 | export default config;
6 |
--------------------------------------------------------------------------------
/frontend/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | const nextConfig: NextConfig = {
4 | /* config options here */
5 | };
6 |
7 | export default nextConfig;
8 |
--------------------------------------------------------------------------------
/backend/src/models/mod.rs:
--------------------------------------------------------------------------------
1 | pub mod user;
2 | pub mod auth_token;
3 | pub mod link;
4 | pub mod email;
5 | pub mod sync_status;
6 |
7 | #[cfg(test)]
8 | mod tests;
9 |
10 | pub use user::User;
11 | pub use auth_token::AuthToken;
12 | pub use link::Link;
13 | pub use email::Email;
14 | pub use sync_status::{SyncStatus, SyncStatusType};
--------------------------------------------------------------------------------
/frontend/public/window.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/public/file.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/.env.example:
--------------------------------------------------------------------------------
1 | DATABASE_URL=sqlite:gmail_link_queue.db
2 |
3 | # Server Configuration
4 | PORT=3002
5 |
6 | # Google OAuth2 Configuration
7 | GOOGLE_CLIENT_ID=your_google_client_id_here
8 | GOOGLE_CLIENT_SECRET=your_google_client_secret_here
9 | GOOGLE_REDIRECT_URI=http://localhost:3002/api/auth/callback
10 |
11 | # Token encryption key (change in production)
12 | TOKEN_ENCRYPTION_KEY=your_secure_encryption_key_here
--------------------------------------------------------------------------------
/frontend/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { dirname } from "path";
2 | import { fileURLToPath } from "url";
3 | import { FlatCompat } from "@eslint/eslintrc";
4 |
5 | const __filename = fileURLToPath(import.meta.url);
6 | const __dirname = dirname(__filename);
7 |
8 | const compat = new FlatCompat({
9 | baseDirectory: __dirname,
10 | });
11 |
12 | const eslintConfig = [
13 | ...compat.extends("next/core-web-vitals", "next/typescript"),
14 | ];
15 |
16 | export default eslintConfig;
17 |
--------------------------------------------------------------------------------
/frontend/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
3 | :root {
4 | --background: #ffffff;
5 | --foreground: #171717;
6 | }
7 |
8 | @theme inline {
9 | --color-background: var(--background);
10 | --color-foreground: var(--foreground);
11 | --font-sans: var(--font-geist-sans);
12 | --font-mono: var(--font-geist-mono);
13 | }
14 |
15 | @media (prefers-color-scheme: dark) {
16 | :root {
17 | --background: #0a0a0a;
18 | --foreground: #ededed;
19 | }
20 | }
21 |
22 | body {
23 | background: var(--background);
24 | color: var(--foreground);
25 | font-family: Arial, Helvetica, sans-serif;
26 | }
27 |
--------------------------------------------------------------------------------
/backend/src/database.rs:
--------------------------------------------------------------------------------
1 | use anyhow::Result;
2 | use sqlx::{sqlite::SqlitePool, Pool, Sqlite};
3 |
4 | pub type Database = Pool;
5 |
6 | pub async fn create_connection_pool(database_url: &str) -> Result {
7 | let pool = SqlitePool::connect(database_url).await?;
8 | Ok(pool)
9 | }
10 |
11 | pub async fn run_migrations(pool: &Database) -> Result<()> {
12 | sqlx::migrate!("./migrations").run(pool).await?;
13 | Ok(())
14 | }
15 |
16 | pub async fn initialize_database(database_url: &str) -> Result {
17 | let pool = create_connection_pool(database_url).await?;
18 | run_migrations(&pool).await?;
19 | Ok(pool)
20 | }
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # env files (can opt-in for committing if needed)
34 | .env*
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbopack",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "react": "^19.0.0",
13 | "react-dom": "^19.0.0",
14 | "next": "15.3.5"
15 | },
16 | "devDependencies": {
17 | "typescript": "^5",
18 | "@types/node": "^20",
19 | "@types/react": "^19",
20 | "@types/react-dom": "^19",
21 | "@tailwindcss/postcss": "^4",
22 | "tailwindcss": "^4",
23 | "eslint": "^9",
24 | "eslint-config-next": "15.3.5",
25 | "@eslint/eslintrc": "^3"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/backend/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "gmail-link-queue-backend"
3 | version = "0.1.0"
4 | edition = "2021"
5 |
6 | [dependencies]
7 | axum = "0.7"
8 | sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid", "migrate"] }
9 | tokio = { version = "1.0", features = ["full"] }
10 | serde = { version = "1.0", features = ["derive"] }
11 | serde_json = "1.0"
12 | anyhow = "1.0"
13 | thiserror = "1.0"
14 | chrono = { version = "0.4", features = ["serde"] }
15 | uuid = { version = "1.0", features = ["v4", "serde"] }
16 | tower = "0.4"
17 | tower-http = { version = "0.5", features = ["cors"] }
18 | dotenv = "0.15"
19 | oauth2 = "4.4"
20 | reqwest = { version = "0.11", features = ["json"] }
21 | url = "2.4"
22 | base64 = "0.21"
23 | rand = "0.8"
24 |
25 | [dev-dependencies]
26 | serde_urlencoded = "0.7"
27 |
--------------------------------------------------------------------------------
/frontend/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Geist, Geist_Mono } from "next/font/google";
3 | import "./globals.css";
4 |
5 | const geistSans = Geist({
6 | variable: "--font-geist-sans",
7 | subsets: ["latin"],
8 | });
9 |
10 | const geistMono = Geist_Mono({
11 | variable: "--font-geist-mono",
12 | subsets: ["latin"],
13 | });
14 |
15 | export const metadata: Metadata = {
16 | title: "Create Next App",
17 | description: "Generated by create next app",
18 | };
19 |
20 | export default function RootLayout({
21 | children,
22 | }: Readonly<{
23 | children: React.ReactNode;
24 | }>) {
25 | return (
26 |
27 |
30 | {children}
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/frontend/public/globe.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export interface User {
2 | id: string;
3 | email: string;
4 | created_at: string;
5 | updated_at: string;
6 | }
7 |
8 | export interface Link {
9 | id: string;
10 | user_id: string;
11 | email_id: string;
12 | url: string;
13 | title?: string;
14 | favicon_url?: string;
15 | is_read: boolean;
16 | read_at?: string;
17 | extracted_at: string;
18 | }
19 |
20 | export interface Email {
21 | id: string;
22 | user_id: string;
23 | gmail_message_id: string;
24 | subject?: string;
25 | sent_date: string;
26 | processed_at: string;
27 | }
28 |
29 | export enum SyncStatusType {
30 | Success = 'success',
31 | Error = 'error',
32 | InProgress = 'in_progress',
33 | }
34 |
35 | export interface SyncStatus {
36 | user_id: string;
37 | last_sync_at?: string;
38 | last_sync_status?: string;
39 | last_error_message?: string;
40 | next_sync_at?: string;
41 | }
42 |
43 | export interface AuthTokens {
44 | access_token: string;
45 | refresh_token: string;
46 | expires_at: string;
47 | }
48 |
49 | export interface ApiResponse {
50 | data?: T;
51 | error?: string;
52 | message?: string;
53 | }
54 |
55 | export interface LinkStats {
56 | total: number;
57 | read: number;
58 | unread: number;
59 | }
--------------------------------------------------------------------------------
/frontend/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | # or
14 | bun dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 |
21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22 |
23 | ## Learn More
24 |
25 | To learn more about Next.js, take a look at the following resources:
26 |
27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 |
30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31 |
32 | ## Deploy on Vercel
33 |
34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35 |
36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
37 |
--------------------------------------------------------------------------------
/backend/migrations/001_initial_schema.sql:
--------------------------------------------------------------------------------
1 | -- Users table for authentication data
2 | CREATE TABLE users (
3 | id TEXT PRIMARY KEY,
4 | email TEXT NOT NULL UNIQUE,
5 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
6 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
7 | );
8 |
9 | -- OAuth tokens storage
10 | CREATE TABLE auth_tokens (
11 | user_id TEXT PRIMARY KEY,
12 | access_token TEXT NOT NULL,
13 | refresh_token TEXT NOT NULL,
14 | expires_at DATETIME NOT NULL,
15 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
16 | FOREIGN KEY (user_id) REFERENCES users(id)
17 | );
18 |
19 | -- Email metadata
20 | CREATE TABLE emails (
21 | id TEXT PRIMARY KEY,
22 | user_id TEXT NOT NULL,
23 | gmail_message_id TEXT NOT NULL UNIQUE,
24 | subject TEXT,
25 | sent_date DATETIME NOT NULL,
26 | processed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
27 | FOREIGN KEY (user_id) REFERENCES users(id)
28 | );
29 |
30 | -- Extracted links
31 | CREATE TABLE links (
32 | id TEXT PRIMARY KEY,
33 | user_id TEXT NOT NULL,
34 | email_id TEXT NOT NULL,
35 | url TEXT NOT NULL,
36 | title TEXT,
37 | favicon_url TEXT,
38 | is_read BOOLEAN DEFAULT FALSE,
39 | read_at DATETIME,
40 | extracted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
41 | FOREIGN KEY (user_id) REFERENCES users(id),
42 | FOREIGN KEY (email_id) REFERENCES emails(id)
43 | );
44 |
45 | -- Sync status tracking
46 | CREATE TABLE sync_status (
47 | user_id TEXT PRIMARY KEY,
48 | last_sync_at DATETIME,
49 | last_sync_status TEXT, -- 'success', 'error', 'in_progress'
50 | last_error_message TEXT,
51 | next_sync_at DATETIME,
52 | FOREIGN KEY (user_id) REFERENCES users(id)
53 | );
--------------------------------------------------------------------------------
/backend/src/main.rs:
--------------------------------------------------------------------------------
1 | mod database;
2 | mod handlers;
3 | mod middleware;
4 | mod models;
5 | mod services;
6 |
7 | use anyhow::Result;
8 | use axum::{routing::{get, post}, Router};
9 | use std::sync::Arc;
10 | use tower_http::cors::CorsLayer;
11 |
12 | use handlers::auth::AppState;
13 | use services::auth::AuthService;
14 |
15 | #[tokio::main]
16 | async fn main() -> Result<()> {
17 | // Load environment variables
18 | dotenv::dotenv().ok();
19 |
20 | // Initialize database
21 | let database_url = std::env::var("DATABASE_URL")
22 | .unwrap_or_else(|_| "sqlite:./gmail_link_queue.db".to_string());
23 | let db_pool = database::initialize_database(&database_url).await?;
24 |
25 | // Initialize auth service
26 | let auth_service = Arc::new(AuthService::new(Arc::new(db_pool))?);
27 |
28 | // Create app state
29 | let app_state = AppState {
30 | auth_service: auth_service.clone(),
31 | };
32 |
33 | // Create router with auth routes
34 | let app = Router::new()
35 | .route("/", get(|| async { "Gmail Link Queue Backend" }))
36 | .route("/api/auth/initiate", get(handlers::auth::initiate_auth))
37 | .route("/api/auth/callback", get(handlers::auth::oauth_callback))
38 | .route("/api/auth/refresh", post(handlers::auth::refresh_token))
39 | .route("/api/auth/status", get(handlers::auth::auth_status))
40 | .with_state(app_state)
41 | .layer(CorsLayer::permissive());
42 |
43 | // Start server
44 | let port = std::env::var("PORT").unwrap_or_else(|_| "3001".to_string());
45 | let bind_address = format!("0.0.0.0:{}", port);
46 | let listener = tokio::net::TcpListener::bind(&bind_address).await?;
47 | println!("Server running on http://localhost:{}", port);
48 | println!("Auth endpoints:");
49 | println!(" GET /api/auth/initiate - Start OAuth flow");
50 | println!(" GET /api/auth/callback - OAuth callback");
51 | println!(" POST /api/auth/refresh - Refresh token");
52 | println!(" GET /api/auth/status - Check auth status");
53 |
54 | axum::serve(listener, app).await?;
55 |
56 | Ok(())
57 | }
58 |
--------------------------------------------------------------------------------
/backend/src/models/user.rs:
--------------------------------------------------------------------------------
1 | use chrono::{DateTime, Utc};
2 | use serde::{Deserialize, Serialize};
3 | use sqlx::{FromRow, SqlitePool};
4 | use anyhow::Result;
5 |
6 | #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
7 | pub struct User {
8 | pub id: String,
9 | pub email: String,
10 | pub created_at: DateTime,
11 | pub updated_at: DateTime,
12 | }
13 |
14 | impl User {
15 | pub fn new(email: String) -> Self {
16 | let now = Utc::now();
17 | Self {
18 | id: uuid::Uuid::new_v4().to_string(),
19 | email,
20 | created_at: now,
21 | updated_at: now,
22 | }
23 | }
24 |
25 | pub async fn create(&self, pool: &SqlitePool) -> Result<()> {
26 | sqlx::query("INSERT INTO users (id, email, created_at, updated_at) VALUES (?, ?, ?, ?)")
27 | .bind(&self.id)
28 | .bind(&self.email)
29 | .bind(&self.created_at)
30 | .bind(&self.updated_at)
31 | .execute(pool)
32 | .await?;
33 | Ok(())
34 | }
35 |
36 | pub async fn find_by_id(pool: &SqlitePool, id: &str) -> Result