Add onboarding email campaign system with post-verification welcome email

Implements automated onboarding emails to encourage user engagement:
- Post-verification welcome email with 5 tips (sent after email verification)
- "No Residence" email (2+ days after registration with no property)
- "No Tasks" email (5+ days after first residence with no tasks)

Key features:
- Each onboarding email type sent only once per user (enforced by unique constraint)
- Email open tracking via tracking pixel endpoint
- Daily scheduled job at 10:00 AM UTC to process eligible users
- Admin panel UI for viewing sent emails, stats, and manual sending
- Admin can send any email type to users from the user detail Testing section

New files:
- internal/models/onboarding_email.go - Database model with tracking
- internal/services/onboarding_email_service.go - Business logic and eligibility queries
- internal/handlers/tracking_handler.go - Email open tracking endpoint
- internal/admin/handlers/onboarding_handler.go - Admin API endpoints
- admin/src/app/(dashboard)/onboarding-emails/ - Admin UI pages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-08 14:36:50 -06:00
parent e152a6308a
commit 9761156597
17 changed files with 1707 additions and 18 deletions

View File

@@ -0,0 +1,298 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ColumnDef } from '@tanstack/react-table';
import Link from 'next/link';
import { MoreHorizontal, Mail, MailOpen, TrendingUp } from 'lucide-react';
import { onboardingEmailsApi } from '@/lib/api';
import type { OnboardingEmail } from '@/lib/api';
import { DataTable } from '@/components/data-table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
const emailTypeLabels: Record<string, string> = {
no_residence: 'No Residence',
no_tasks: 'No Tasks',
};
const emailTypeColors: Record<string, 'default' | 'secondary' | 'outline'> = {
no_residence: 'default',
no_tasks: 'secondary',
};
const columns: ColumnDef<OnboardingEmail>[] = [
{
accessorKey: 'id',
header: 'ID',
cell: ({ row }) => (
<span className="font-mono text-sm">{row.original.id}</span>
),
},
{
accessorKey: 'user_name',
header: 'User',
cell: ({ row }) => (
<div>
<div className="font-medium">{row.original.user_name || 'Unknown'}</div>
<div className="text-sm text-muted-foreground">{row.original.user_email}</div>
</div>
),
},
{
accessorKey: 'email_type',
header: 'Type',
cell: ({ row }) => (
<Badge variant={emailTypeColors[row.original.email_type] || 'outline'}>
{emailTypeLabels[row.original.email_type] || row.original.email_type}
</Badge>
),
},
{
accessorKey: 'sent_at',
header: 'Sent',
cell: ({ row }) => {
const date = new Date(row.original.sent_at);
return (
<div>
<div>{date.toLocaleDateString()}</div>
<div className="text-sm text-muted-foreground">{date.toLocaleTimeString()}</div>
</div>
);
},
},
{
accessorKey: 'opened_at',
header: 'Opened',
cell: ({ row }) => {
if (row.original.opened_at) {
const date = new Date(row.original.opened_at);
return (
<div className="flex items-center gap-2">
<MailOpen className="h-4 w-4 text-green-500" />
<div>
<div>{date.toLocaleDateString()}</div>
<div className="text-sm text-muted-foreground">{date.toLocaleTimeString()}</div>
</div>
</div>
);
}
return (
<div className="flex items-center gap-2 text-muted-foreground">
<Mail className="h-4 w-4" />
<span>Not opened</span>
</div>
);
},
},
{
accessorKey: 'tracking_id',
header: 'Tracking ID',
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">
{row.original.tracking_id.substring(0, 12)}...
</span>
),
},
{
id: 'actions',
cell: ({ row }) => {
const email = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link href={`/admin/users/${email.user_id}`}>View user</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
function StatsCards() {
const { data: stats, isLoading } = useQuery({
queryKey: ['onboarding-emails-stats'],
queryFn: () => onboardingEmailsApi.getStats(),
});
if (isLoading) {
return (
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-5">
{[...Array(5)].map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Loading...</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">-</div>
</CardContent>
</Card>
))}
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-5">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Sent</CardTitle>
<Mail className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.total_sent ?? 0}</div>
<p className="text-xs text-muted-foreground">
{stats?.total_opened ?? 0} opened
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Overall Open Rate</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats?.overall_open_rate?.toFixed(1) ?? 0}%
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">No Residence</CardTitle>
<Badge variant="default" className="text-xs">Type 1</Badge>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.no_residence_total ?? 0}</div>
<p className="text-xs text-muted-foreground">
{stats?.no_residence_open_rate?.toFixed(1) ?? 0}% open rate
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">No Tasks</CardTitle>
<Badge variant="secondary" className="text-xs">Type 2</Badge>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.no_tasks_total ?? 0}</div>
<p className="text-xs text-muted-foreground">
{stats?.no_tasks_open_rate?.toFixed(1) ?? 0}% open rate
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Opened</CardTitle>
<MailOpen className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.total_opened ?? 0}</div>
<p className="text-xs text-muted-foreground">
of {stats?.total_sent ?? 0} sent
</p>
</CardContent>
</Card>
</div>
);
}
export default function OnboardingEmailsPage() {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [emailType, setEmailType] = useState<string>('all');
const [openedFilter, setOpenedFilter] = useState<string>('all');
const { data, isLoading } = useQuery({
queryKey: ['onboarding-emails', { page, page_size: pageSize, email_type: emailType, opened: openedFilter }],
queryFn: () => onboardingEmailsApi.list({
page,
page_size: pageSize,
email_type: emailType !== 'all' ? emailType : undefined,
opened: openedFilter !== 'all' ? openedFilter as 'true' | 'false' : undefined,
}),
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Onboarding Emails</h1>
<p className="text-muted-foreground">
Track automated onboarding email campaigns and open rates
</p>
</div>
<StatsCards />
<div className="flex gap-4">
<div className="w-48">
<Select value={emailType} onValueChange={(value) => { setEmailType(value); setPage(1); }}>
<SelectTrigger>
<SelectValue placeholder="Email type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="no_residence">No Residence</SelectItem>
<SelectItem value="no_tasks">No Tasks</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-48">
<Select value={openedFilter} onValueChange={(value) => { setOpenedFilter(value); setPage(1); }}>
<SelectTrigger>
<SelectValue placeholder="Opened status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="true">Opened</SelectItem>
<SelectItem value="false">Not Opened</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DataTable
columns={columns}
data={data?.data ?? []}
totalCount={data?.total ?? 0}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
isLoading={isLoading}
/>
</div>
);
}

View File

@@ -4,10 +4,10 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Edit, Trash2, Bell, Mail } from 'lucide-react';
import { ArrowLeft, Edit, Trash2, Bell, Mail, MailCheck, Home, ClipboardList, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import { usersApi, notificationsApi, emailsApi, subscriptionsApi } from '@/lib/api';
import { usersApi, notificationsApi, emailsApi, subscriptionsApi, onboardingEmailsApi } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
@@ -116,6 +116,31 @@ export function UserDetailClient() {
},
});
const sendOnboardingEmailMutation = useMutation({
mutationFn: (emailType: 'no_residence' | 'no_tasks') => onboardingEmailsApi.send({
user_id: userId,
email_type: emailType,
}),
onSuccess: (data) => {
toast.success(`Onboarding email sent to ${data.email}`);
},
onError: (error: Error & { response?: { data?: { error?: string; details?: string } } }) => {
const errorMsg = error.response?.data?.error || error.response?.data?.details || error.message;
toast.error(`Failed to send onboarding email: ${errorMsg}`);
},
});
const sendPostVerificationEmailMutation = useMutation({
mutationFn: () => emailsApi.sendPostVerificationEmail({ user_id: userId }),
onSuccess: (data) => {
toast.success(`Post-verification email sent to ${data.to}`);
},
onError: (error: Error & { response?: { data?: { error?: string; details?: string } } }) => {
const errorMsg = error.response?.data?.error || error.response?.data?.details || error.message;
toast.error(`Failed to send email: ${errorMsg}`);
},
});
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
@@ -383,9 +408,10 @@ export function UserDetailClient() {
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Testing</CardTitle>
<CardDescription>Send test notifications and emails to this user</CardDescription>
<CardDescription>Send test notifications, emails, and onboarding campaigns to this user</CardDescription>
</CardHeader>
<CardContent className="flex gap-4">
<CardContent className="space-y-4">
<div className="flex gap-4 flex-wrap">
{/* Push Notification Dialog */}
<Dialog open={showPushDialog} onOpenChange={setShowPushDialog}>
<DialogTrigger asChild>
@@ -485,6 +511,55 @@ export function UserDetailClient() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<Separator />
{/* Onboarding Emails */}
<div>
<Label className="text-base font-medium">Onboarding Emails</Label>
<p className="text-sm text-muted-foreground mb-3">
Send onboarding campaign emails to encourage user engagement
</p>
<div className="flex gap-4 flex-wrap">
<Button
variant="outline"
onClick={() => {
if (confirm('Send "Post-Verification" welcome email with tips to this user?')) {
sendPostVerificationEmailMutation.mutate();
}
}}
disabled={sendPostVerificationEmailMutation.isPending}
>
<Sparkles className="mr-2 h-4 w-4" />
{sendPostVerificationEmailMutation.isPending ? 'Sending...' : 'Welcome Tips Email'}
</Button>
<Button
variant="outline"
onClick={() => {
if (confirm('Send "No Residence" onboarding email to this user?')) {
sendOnboardingEmailMutation.mutate('no_residence');
}
}}
disabled={sendOnboardingEmailMutation.isPending}
>
<Home className="mr-2 h-4 w-4" />
{sendOnboardingEmailMutation.isPending ? 'Sending...' : 'No Residence Email'}
</Button>
<Button
variant="outline"
onClick={() => {
if (confirm('Send "No Tasks" onboarding email to this user?')) {
sendOnboardingEmailMutation.mutate('no_tasks');
}
}}
disabled={sendOnboardingEmailMutation.isPending}
>
<ClipboardList className="mr-2 h-4 w-4" />
{sendOnboardingEmailMutation.isPending ? 'Sending...' : 'No Tasks Email'}
</Button>
</div>
</div>
</CardContent>
</Card>
</div>

View File

@@ -20,6 +20,7 @@ import {
Layers,
Sparkles,
Mail,
MailCheck,
Share2,
KeyRound,
Smartphone,
@@ -63,6 +64,7 @@ const menuItems = [
{ title: 'Document Images', url: '/admin/document-images', icon: ImagePlus },
{ title: 'Notifications', url: '/admin/notifications', icon: Bell },
{ title: 'Notification Prefs', url: '/admin/notification-prefs', icon: BellRing },
{ title: 'Onboarding Emails', url: '/admin/onboarding-emails', icon: MailCheck },
{ title: 'Devices', url: '/admin/devices', icon: Smartphone },
{ title: 'Subscriptions', url: '/admin/subscriptions', icon: CreditCard },
];

View File

@@ -321,6 +321,11 @@ export const emailsApi = {
const response = await api.post('/emails/send-test', data);
return response.data;
},
sendPostVerificationEmail: async (data: { user_id: number }): Promise<{ message: string; to: string }> => {
const response = await api.post('/emails/send-post-verification', data);
return response.data;
},
};
// Subscriptions API
@@ -1499,4 +1504,99 @@ export const documentImagesApi = {
},
};
// Onboarding Email Types
export interface OnboardingEmail {
id: number;
user_id: number;
user_email?: string;
user_name?: string;
email_type: 'no_residence' | 'no_tasks';
sent_at: string;
opened_at?: string;
tracking_id: string;
created_at: string;
}
export interface OnboardingEmailListParams {
page?: number;
page_size?: number;
email_type?: string;
user_id?: number;
opened?: 'true' | 'false';
}
export interface OnboardingEmailListResponse {
data: OnboardingEmail[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
export interface OnboardingEmailStats {
no_residence_total: number;
no_residence_opened: number;
no_residence_open_rate: number;
no_tasks_total: number;
no_tasks_opened: number;
no_tasks_open_rate: number;
total_sent: number;
total_opened: number;
overall_open_rate: number;
}
export interface OnboardingEmailsByUserResponse {
data: OnboardingEmail[];
user_id: number;
count: number;
}
export interface SendOnboardingEmailRequest {
user_id: number;
email_type: 'no_residence' | 'no_tasks';
}
export interface SendOnboardingEmailResponse {
message: string;
user_id: number;
email: string;
email_type: string;
}
// Onboarding Emails API
export const onboardingEmailsApi = {
list: async (params?: OnboardingEmailListParams): Promise<OnboardingEmailListResponse> => {
const response = await api.get<OnboardingEmailListResponse>('/onboarding-emails', { params });
return response.data;
},
get: async (id: number): Promise<OnboardingEmail> => {
const response = await api.get<OnboardingEmail>(`/onboarding-emails/${id}`);
return response.data;
},
getByUser: async (userId: number): Promise<OnboardingEmailsByUserResponse> => {
const response = await api.get<OnboardingEmailsByUserResponse>(`/onboarding-emails/user/${userId}`);
return response.data;
},
getStats: async (): Promise<OnboardingEmailStats> => {
const response = await api.get<OnboardingEmailStats>('/onboarding-emails/stats');
return response.data;
},
send: async (data: SendOnboardingEmailRequest): Promise<SendOnboardingEmailResponse> => {
const response = await api.post<SendOnboardingEmailResponse>('/onboarding-emails/send', data);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`/onboarding-emails/${id}`);
},
bulkDelete: async (ids: number[]): Promise<void> => {
await api.delete('/onboarding-emails/bulk', { data: { ids } });
},
};
export default api;

View File

@@ -100,6 +100,7 @@ func main() {
mux.HandleFunc(jobs.TypeDailyDigest, jobHandler.HandleDailyDigest)
mux.HandleFunc(jobs.TypeSendEmail, jobHandler.HandleSendEmail)
mux.HandleFunc(jobs.TypeSendPush, jobHandler.HandleSendPush)
mux.HandleFunc(jobs.TypeOnboardingEmails, jobHandler.HandleOnboardingEmails)
// Start scheduler for periodic tasks
scheduler := asynq.NewScheduler(redisOpt, nil)
@@ -124,6 +125,13 @@ func main() {
}
log.Info().Str("cron", "0 * * * *").Int("default_hour", cfg.Worker.DailyNotifHour).Msg("Registered daily digest job (runs hourly for per-user times)")
// Schedule onboarding emails (runs daily at 10:00 AM UTC)
// Sends emails to users who haven't created residences or tasks after registration
if _, err := scheduler.Register("0 10 * * *", asynq.NewTask(jobs.TypeOnboardingEmails, nil)); err != nil {
log.Fatal().Err(err).Msg("Failed to register onboarding emails job")
}
log.Info().Str("cron", "0 10 * * *").Msg("Registered onboarding emails job (runs daily at 10:00 AM UTC)")
// Handle graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

View File

@@ -402,3 +402,50 @@ func (h *AdminNotificationHandler) SendTestEmail(c *gin.Context) {
"to": user.Email,
})
}
// SendPostVerificationEmail handles POST /api/admin/emails/send-post-verification
func (h *AdminNotificationHandler) SendPostVerificationEmail(c *gin.Context) {
var req struct {
UserID uint `json:"user_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
return
}
// Verify user exists
var user models.User
if err := h.db.First(&user, req.UserID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user"})
return
}
if user.Email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "User has no email address"})
return
}
// Send email
if h.emailService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Email service not configured"})
return
}
err := h.emailService.SendPostVerificationEmail(user.Email, user.FirstName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to send email",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Post-verification email sent successfully",
"to": user.Email,
})
}

View File

@@ -0,0 +1,352 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
)
// AdminOnboardingHandler handles admin onboarding email operations
type AdminOnboardingHandler struct {
db *gorm.DB
onboardingService *services.OnboardingEmailService
}
// NewAdminOnboardingHandler creates a new admin onboarding handler
func NewAdminOnboardingHandler(db *gorm.DB, onboardingService *services.OnboardingEmailService) *AdminOnboardingHandler {
return &AdminOnboardingHandler{
db: db,
onboardingService: onboardingService,
}
}
// OnboardingEmailResponse represents an onboarding email in API responses
type OnboardingEmailResponse struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
UserEmail string `json:"user_email,omitempty"`
UserName string `json:"user_name,omitempty"`
EmailType string `json:"email_type"`
SentAt string `json:"sent_at"`
OpenedAt *string `json:"opened_at,omitempty"`
TrackingID string `json:"tracking_id"`
CreatedAt string `json:"created_at"`
}
// OnboardingEmailListResponse is the paginated list response
type OnboardingEmailListResponse struct {
Data []OnboardingEmailResponse `json:"data"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
// OnboardingStatsResponse contains email statistics
type OnboardingStatsResponse struct {
NoResidenceTotal int64 `json:"no_residence_total"`
NoResidenceOpened int64 `json:"no_residence_opened"`
NoResidenceRate float64 `json:"no_residence_open_rate"`
NoTasksTotal int64 `json:"no_tasks_total"`
NoTasksOpened int64 `json:"no_tasks_opened"`
NoTasksRate float64 `json:"no_tasks_open_rate"`
TotalSent int64 `json:"total_sent"`
TotalOpened int64 `json:"total_opened"`
OverallRate float64 `json:"overall_open_rate"`
}
// List returns paginated list of onboarding emails
// GET /api/admin/onboarding-emails
func (h *AdminOnboardingHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
emailType := c.Query("email_type")
userID, _ := strconv.Atoi(c.Query("user_id"))
opened := c.Query("opened")
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
offset := (page - 1) * pageSize
// Build query
query := h.db.Model(&models.OnboardingEmail{}).Preload("User")
if emailType != "" {
query = query.Where("email_type = ?", emailType)
}
if userID > 0 {
query = query.Where("user_id = ?", userID)
}
if opened == "true" {
query = query.Where("opened_at IS NOT NULL")
} else if opened == "false" {
query = query.Where("opened_at IS NULL")
}
// Count total
var total int64
if err := query.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to count emails"})
return
}
// Get paginated results
var emails []models.OnboardingEmail
if err := query.Order("sent_at DESC").Offset(offset).Limit(pageSize).Find(&emails).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch emails"})
return
}
// Transform to response
data := make([]OnboardingEmailResponse, len(emails))
for i, email := range emails {
data[i] = transformOnboardingEmail(email)
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
c.JSON(http.StatusOK, OnboardingEmailListResponse{
Data: data,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
})
}
// GetStats returns onboarding email statistics
// GET /api/admin/onboarding-emails/stats
func (h *AdminOnboardingHandler) GetStats(c *gin.Context) {
var stats OnboardingStatsResponse
// No residence email stats
h.db.Model(&models.OnboardingEmail{}).
Where("email_type = ?", models.OnboardingEmailNoResidence).
Count(&stats.NoResidenceTotal)
h.db.Model(&models.OnboardingEmail{}).
Where("email_type = ? AND opened_at IS NOT NULL", models.OnboardingEmailNoResidence).
Count(&stats.NoResidenceOpened)
// No tasks email stats
h.db.Model(&models.OnboardingEmail{}).
Where("email_type = ?", models.OnboardingEmailNoTasks).
Count(&stats.NoTasksTotal)
h.db.Model(&models.OnboardingEmail{}).
Where("email_type = ? AND opened_at IS NOT NULL", models.OnboardingEmailNoTasks).
Count(&stats.NoTasksOpened)
// Calculate rates
if stats.NoResidenceTotal > 0 {
stats.NoResidenceRate = float64(stats.NoResidenceOpened) / float64(stats.NoResidenceTotal) * 100
}
if stats.NoTasksTotal > 0 {
stats.NoTasksRate = float64(stats.NoTasksOpened) / float64(stats.NoTasksTotal) * 100
}
stats.TotalSent = stats.NoResidenceTotal + stats.NoTasksTotal
stats.TotalOpened = stats.NoResidenceOpened + stats.NoTasksOpened
if stats.TotalSent > 0 {
stats.OverallRate = float64(stats.TotalOpened) / float64(stats.TotalSent) * 100
}
c.JSON(http.StatusOK, stats)
}
// GetByUser returns onboarding emails for a specific user
// GET /api/admin/onboarding-emails/user/:user_id
func (h *AdminOnboardingHandler) GetByUser(c *gin.Context) {
userID, err := strconv.ParseUint(c.Param("user_id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
}
var emails []models.OnboardingEmail
if err := h.db.Where("user_id = ?", userID).Order("sent_at DESC").Find(&emails).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch emails"})
return
}
// Transform to response
data := make([]OnboardingEmailResponse, len(emails))
for i, email := range emails {
data[i] = transformOnboardingEmail(email)
}
c.JSON(http.StatusOK, gin.H{
"data": data,
"user_id": userID,
"count": len(data),
})
}
// Get returns a single onboarding email by ID
// GET /api/admin/onboarding-emails/:id
func (h *AdminOnboardingHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
var email models.OnboardingEmail
if err := h.db.Preload("User").First(&email, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Email not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch email"})
return
}
c.JSON(http.StatusOK, transformOnboardingEmail(email))
}
// Delete removes an onboarding email record
// DELETE /api/admin/onboarding-emails/:id
func (h *AdminOnboardingHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
result := h.db.Delete(&models.OnboardingEmail{}, id)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete email"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Email not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Email record deleted"})
}
// BulkDelete removes multiple onboarding email records
// DELETE /api/admin/onboarding-emails/bulk
func (h *AdminOnboardingHandler) BulkDelete(c *gin.Context) {
var req struct {
IDs []uint `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
if len(req.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No IDs provided"})
return
}
result := h.db.Delete(&models.OnboardingEmail{}, req.IDs)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete emails"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Emails deleted",
"count": result.RowsAffected,
})
}
// SendOnboardingEmailRequest represents a request to send an onboarding email
type SendOnboardingEmailRequest struct {
UserID uint `json:"user_id" binding:"required"`
EmailType string `json:"email_type" binding:"required"`
}
// Send sends an onboarding email to a specific user
// POST /api/admin/onboarding-emails/send
func (h *AdminOnboardingHandler) Send(c *gin.Context) {
if h.onboardingService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Onboarding email service not configured"})
return
}
var req SendOnboardingEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: user_id and email_type are required"})
return
}
// Validate email type
var emailType models.OnboardingEmailType
switch req.EmailType {
case "no_residence":
emailType = models.OnboardingEmailNoResidence
case "no_tasks":
emailType = models.OnboardingEmailNoTasks
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid email_type. Must be 'no_residence' or 'no_tasks'"})
return
}
// Get user email for response
var user models.User
if err := h.db.First(&user, req.UserID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user"})
return
}
// Send the email
if err := h.onboardingService.SendOnboardingEmailToUser(req.UserID, emailType); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Onboarding email sent successfully",
"user_id": req.UserID,
"email": user.Email,
"email_type": req.EmailType,
})
}
// transformOnboardingEmail converts a model to response format
func transformOnboardingEmail(email models.OnboardingEmail) OnboardingEmailResponse {
resp := OnboardingEmailResponse{
ID: email.ID,
UserID: email.UserID,
EmailType: string(email.EmailType),
SentAt: email.SentAt.Format("2006-01-02T15:04:05Z"),
TrackingID: email.TrackingID,
CreatedAt: email.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
if email.OpenedAt != nil {
opened := email.OpenedAt.Format("2006-01-02T15:04:05Z")
resp.OpenedAt = &opened
}
// Include user info if loaded
if email.User.ID != 0 {
resp.UserEmail = email.User.Email
if email.User.FirstName != "" || email.User.LastName != "" {
resp.UserName = email.User.FirstName + " " + email.User.LastName
} else {
resp.UserName = email.User.Username
}
}
return resp
}

View File

@@ -19,8 +19,9 @@ import (
// Dependencies holds optional services for admin routes
type Dependencies struct {
EmailService *services.EmailService
PushClient *push.Client
EmailService *services.EmailService
PushClient *push.Client
OnboardingService *services.OnboardingEmailService
}
// SetupRoutes configures all admin routes
@@ -134,6 +135,7 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
emails := protected.Group("/emails")
{
emails.POST("/send-test", notificationHandler.SendTestEmail)
emails.POST("/send-post-verification", notificationHandler.SendPostVerificationEmail)
}
// Subscription management
@@ -414,6 +416,23 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
limitations.PUT("/upgrade-triggers/:id", limitationsHandler.UpdateUpgradeTrigger)
limitations.DELETE("/upgrade-triggers/:id", limitationsHandler.DeleteUpgradeTrigger)
}
// Onboarding emails management
var onboardingService *services.OnboardingEmailService
if deps != nil {
onboardingService = deps.OnboardingService
}
onboardingHandler := handlers.NewAdminOnboardingHandler(db, onboardingService)
onboardingEmails := protected.Group("/onboarding-emails")
{
onboardingEmails.GET("", onboardingHandler.List)
onboardingEmails.GET("/stats", onboardingHandler.GetStats)
onboardingEmails.POST("/send", onboardingHandler.Send)
onboardingEmails.DELETE("/bulk", onboardingHandler.BulkDelete)
onboardingEmails.GET("/user/:user_id", onboardingHandler.GetByUser)
onboardingEmails.GET("/:id", onboardingHandler.Get)
onboardingEmails.DELETE("/:id", onboardingHandler.Delete)
}
}
}

View File

@@ -30,6 +30,7 @@ type ServerConfig struct {
AllowedHosts []string
Timezone string
StaticDir string // Directory for static landing page files
BaseURL string // Public base URL for email tracking links (e.g., https://casera.app)
}
type DatabaseConfig struct {
@@ -146,6 +147,7 @@ func Load() (*Config, error) {
AllowedHosts: strings.Split(viper.GetString("ALLOWED_HOSTS"), ","),
Timezone: viper.GetString("TIMEZONE"),
StaticDir: viper.GetString("STATIC_DIR"),
BaseURL: viper.GetString("BASE_URL"),
},
Database: dbConfig,
Redis: RedisConfig{
@@ -214,6 +216,7 @@ func setDefaults() {
viper.SetDefault("ALLOWED_HOSTS", "localhost,127.0.0.1")
viper.SetDefault("TIMEZONE", "UTC")
viper.SetDefault("STATIC_DIR", "/app/static")
viper.SetDefault("BASE_URL", "https://casera.app")
// Database defaults
viper.SetDefault("DB_HOST", "localhost")

View File

@@ -152,12 +152,18 @@ func Migrate() error {
&models.FeatureBenefit{},
&models.Promotion{},
&models.TierLimits{},
// Onboarding email tracking
&models.OnboardingEmail{},
)
if err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
// Add unique constraint for onboarding emails (one email type per user)
db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_onboarding_emails_user_type ON onboarding_emails(user_id, email_type)`)
// Run GoAdmin migrations
if err := migrateGoAdmin(); err != nil {
return fmt.Errorf("failed to run GoAdmin migrations: %w", err)

View File

@@ -222,6 +222,15 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) {
return
}
// Send post-verification welcome email with tips (async)
if h.emailService != nil {
go func() {
if err := h.emailService.SendPostVerificationEmail(user.Email, user.FirstName); err != nil {
log.Error().Err(err).Str("email", user.Email).Msg("Failed to send post-verification email")
}
}()
}
c.JSON(http.StatusOK, responses.VerifyEmailResponse{
Message: i18n.LocalizedMessage(c, "message.email_verified"),
Verified: true,

View File

@@ -0,0 +1,45 @@
package handlers
import (
"encoding/base64"
"net/http"
"github.com/gin-gonic/gin"
"github.com/treytartt/casera-api/internal/services"
)
// TrackingHandler handles email tracking endpoints
type TrackingHandler struct {
onboardingService *services.OnboardingEmailService
}
// NewTrackingHandler creates a new tracking handler
func NewTrackingHandler(onboardingService *services.OnboardingEmailService) *TrackingHandler {
return &TrackingHandler{
onboardingService: onboardingService,
}
}
// 1x1 transparent GIF (43 bytes)
var transparentGIF, _ = base64.StdEncoding.DecodeString("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7")
// TrackEmailOpen handles email open tracking via tracking pixel
// GET /api/track/open/:trackingID
func (h *TrackingHandler) TrackEmailOpen(c *gin.Context) {
trackingID := c.Param("trackingID")
if trackingID != "" && h.onboardingService != nil {
// Record the open (async, don't block response)
go func() {
_ = h.onboardingService.RecordEmailOpened(trackingID)
}()
}
// Return 1x1 transparent GIF
c.Header("Content-Type", "image/gif")
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
c.Data(http.StatusOK, "image/gif", transparentGIF)
}

View File

@@ -0,0 +1,34 @@
package models
import (
"time"
)
// OnboardingEmailType represents the type of onboarding email
type OnboardingEmailType string
const (
// OnboardingEmailNoResidence is sent when a user has not created a residence after 2 days
OnboardingEmailNoResidence OnboardingEmailType = "no_residence"
// OnboardingEmailNoTasks is sent when a user has created a residence but no tasks after 5 days
OnboardingEmailNoTasks OnboardingEmailType = "no_tasks"
)
// OnboardingEmail tracks sent onboarding emails per user
// Each email type can only be sent once per user (enforced by unique constraint on user_id + email_type)
type OnboardingEmail struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
EmailType OnboardingEmailType `gorm:"type:varchar(50);not null;index" json:"email_type"`
SentAt time.Time `gorm:"not null" json:"sent_at"`
OpenedAt *time.Time `json:"opened_at"`
TrackingID string `gorm:"type:varchar(64);uniqueIndex" json:"tracking_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName returns the table name for OnboardingEmail
func (OnboardingEmail) TableName() string {
return "onboarding_emails"
}

View File

@@ -68,6 +68,13 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
// Health check endpoint (no auth required)
r.GET("/api/health/", healthCheck)
// Initialize onboarding email service for tracking handler
onboardingService := services.NewOnboardingEmailService(deps.DB, deps.EmailService, cfg.Server.BaseURL)
// Email tracking endpoint (no auth required - used by email tracking pixels)
trackingHandler := handlers.NewTrackingHandler(onboardingService)
r.GET("/api/track/open/:trackingID", trackingHandler.TrackEmailOpen)
// NOTE: Public static file serving removed for security.
// All uploaded media is now served through authenticated proxy endpoints:
// - GET /api/media/document/:id
@@ -132,8 +139,9 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
// Set up admin routes (separate auth system)
adminDeps := &admin.Dependencies{
EmailService: deps.EmailService,
PushClient: deps.PushClient,
EmailService: deps.EmailService,
PushClient: deps.PushClient,
OnboardingService: onboardingService,
}
admin.SetupRoutes(r, deps.DB, cfg, adminDeps)

View File

@@ -275,6 +275,116 @@ The Casera Team
return s.SendEmail(to, subject, htmlBody, textBody)
}
// SendPostVerificationEmail sends a welcome email after user verifies their email address
func (s *EmailService) SendPostVerificationEmail(to, firstName string) error {
subject := "You're All Set! Getting Started with Casera"
name := firstName
if name == "" {
name = "there"
}
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<tr>
<td style="background: #FFFFFF; padding: 40px 30px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 18px; font-weight: 600; color: #1a1a1a; margin: 0 0 20px 0;">Hi %s,</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; line-height: 1.6; color: #4B5563; margin: 0 0 24px 0;">Your email is now verified and your Casera account is ready to go! Here are some tips to help you get the most out of the app.</p>
<!-- Tip 1 -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="margin-bottom: 16px;">
<tr>
<td style="background: #F0FDF4; border-left: 4px solid #22C55E; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; font-weight: 700; color: #166534; margin: 0 0 8px 0;">&#127968; Start with Your Property</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 0;">Add your home or rental property to begin tracking everything in one place. You can add multiple properties and even share access with family members or co-owners.</p>
</td>
</tr>
</table>
<!-- Tip 2 -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="margin-bottom: 16px;">
<tr>
<td style="background: #EFF6FF; border-left: 4px solid #3B82F6; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; font-weight: 700; color: #1E40AF; margin: 0 0 8px 0;">&#128197; Set Up Recurring Tasks</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 0;">Create recurring tasks for regular maintenance like HVAC filter changes, gutter cleaning, or lawn care. Casera will remind you when they're due so nothing falls through the cracks.</p>
</td>
</tr>
</table>
<!-- Tip 3 -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="margin-bottom: 16px;">
<tr>
<td style="background: #FEF3C7; border-left: 4px solid #F59E0B; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; font-weight: 700; color: #92400E; margin: 0 0 8px 0;">&#128221; Track Your Maintenance History</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 0;">When you complete a task, add notes and photos to build a maintenance history. This is invaluable for warranty claims, selling your home, or just remembering when something was last serviced.</p>
</td>
</tr>
</table>
<!-- Tip 4 -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="margin-bottom: 16px;">
<tr>
<td style="background: #F5F3FF; border-left: 4px solid #8B5CF6; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; font-weight: 700; color: #5B21B6; margin: 0 0 8px 0;">&#128196; Store Important Documents</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 0;">Upload warranties, appliance manuals, insurance policies, and receipts. When you need them, they'll be right at your fingertips instead of buried in a drawer somewhere.</p>
</td>
</tr>
</table>
<!-- Tip 5 -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="margin-bottom: 24px;">
<tr>
<td style="background: #FDF2F8; border-left: 4px solid #EC4899; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; font-weight: 700; color: #9D174D; margin: 0 0 8px 0;">&#128119; Save Your Contractors</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 0;">Add your trusted plumber, electrician, and other service providers to your contractor list. You'll never have to dig through old emails or papers to find their contact info again.</p>
</td>
</tr>
</table>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #6B7280; margin: 0;">We're excited to have you on board. If you have any questions or feedback, we'd love to hear from you at support@casera.app.</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #6B7280; margin: 30px 0 0 0;">Happy homeowning!<br><strong style="color: #1a1a1a;">The Casera Team</strong></p>
</td>
</tr>
%s`,
emailHeader("You're Verified!"),
name,
emailFooter(time.Now().Year()))
htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent)
textBody := fmt.Sprintf(`
You're All Set! Getting Started with Casera
Hi %s,
Your email is now verified and your Casera account is ready to go! Here are some tips to help you get the most out of the app.
1. START WITH YOUR PROPERTY
Add your home or rental property to begin tracking everything in one place. You can add multiple properties and even share access with family members or co-owners.
2. SET UP RECURRING TASKS
Create recurring tasks for regular maintenance like HVAC filter changes, gutter cleaning, or lawn care. Casera will remind you when they're due so nothing falls through the cracks.
3. TRACK YOUR MAINTENANCE HISTORY
When you complete a task, add notes and photos to build a maintenance history. This is invaluable for warranty claims, selling your home, or just remembering when something was last serviced.
4. STORE IMPORTANT DOCUMENTS
Upload warranties, appliance manuals, insurance policies, and receipts. When you need them, they'll be right at your fingertips instead of buried in a drawer somewhere.
5. SAVE YOUR CONTRACTORS
Add your trusted plumber, electrician, and other service providers to your contractor list. You'll never have to dig through old emails or papers to find their contact info again.
We're excited to have you on board. If you have any questions or feedback, we'd love to hear from you at support@casera.app.
Happy homeowning!
The Casera Team
`, name)
return s.SendEmail(to, subject, htmlBody, textBody)
}
// SendVerificationEmail sends an email verification code
func (s *EmailService) SendVerificationEmail(to, firstName, code string) error {
subject := "Casera - Verify Your Email"
@@ -605,6 +715,162 @@ The Casera Team
return s.SendEmailWithAttachment(to, subject, htmlBody, textBody, attachment)
}
// SendNoResidenceOnboardingEmail sends an onboarding email to users who haven't created a residence
func (s *EmailService) SendNoResidenceOnboardingEmail(to, firstName, baseURL, trackingID string) error {
subject := "Get started with Casera - Add your first property"
name := firstName
if name == "" {
name = "there"
}
trackingPixel := fmt.Sprintf(`<img src="%s/api/track/open/%s" width="1" height="1" style="display:none;" alt="" />`, baseURL, trackingID)
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<tr>
<td style="background: #FFFFFF; padding: 40px 30px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 18px; font-weight: 600; color: #1a1a1a; margin: 0 0 20px 0;">Hi %s,</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; line-height: 1.6; color: #4B5563; margin: 0 0 24px 0;">We noticed you haven't added your first property to Casera yet. Adding a property is the first step to staying on top of your home maintenance!</p>
<!-- Benefits Box -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0">
<tr>
<td style="background: #F8FAFC; border-radius: 12px; padding: 24px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; font-weight: 700; color: #1a1a1a; margin: 0 0 16px 0;">Why add a property?</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#127968; <strong>Track all your homes</strong> - Manage single-family homes, apartments, or investment properties</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#128197; <strong>Never miss maintenance</strong> - Set up recurring tasks with smart reminders</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#128196; <strong>Store important documents</strong> - Keep warranties, manuals, and records in one place</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#128119; <strong>Manage contractors</strong> - Keep your trusted pros organized and accessible</p>
</td>
</tr>
</table>
<!-- CTA Button -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="margin: 32px 0;">
<tr>
<td style="text-align: center;">
<a href="casera://add-property" style="display: inline-block; background: linear-gradient(135deg, #0079FF 0%%, #14B8A6 100%%); color: #FFFFFF; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; font-weight: 700; text-decoration: none; padding: 16px 32px; border-radius: 12px;">Add Your First Property</a>
</td>
</tr>
</table>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #6B7280; margin: 24px 0 0 0;">Just open the Casera app and tap the + button to get started. It only takes a minute!</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #6B7280; margin: 30px 0 0 0;">Best regards,<br><strong style="color: #1a1a1a;">The Casera Team</strong></p>
%s
</td>
</tr>
%s`,
emailHeader("Get Started!"),
name,
trackingPixel,
emailFooter(time.Now().Year()))
htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent)
textBody := fmt.Sprintf(`
Get started with Casera - Add your first property
Hi %s,
We noticed you haven't added your first property to Casera yet. Adding a property is the first step to staying on top of your home maintenance!
Why add a property?
- Track all your homes: Manage single-family homes, apartments, or investment properties
- Never miss maintenance: Set up recurring tasks with smart reminders
- Store important documents: Keep warranties, manuals, and records in one place
- Manage contractors: Keep your trusted pros organized and accessible
Just open the Casera app and tap the + button to get started. It only takes a minute!
Best regards,
The Casera Team
`, name)
return s.SendEmail(to, subject, htmlBody, textBody)
}
// SendNoTasksOnboardingEmail sends an onboarding email to users who have a property but no tasks
func (s *EmailService) SendNoTasksOnboardingEmail(to, firstName, baseURL, trackingID string) error {
subject := "Stay on top of home maintenance with Casera"
name := firstName
if name == "" {
name = "there"
}
trackingPixel := fmt.Sprintf(`<img src="%s/api/track/open/%s" width="1" height="1" style="display:none;" alt="" />`, baseURL, trackingID)
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<tr>
<td style="background: #FFFFFF; padding: 40px 30px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 18px; font-weight: 600; color: #1a1a1a; margin: 0 0 20px 0;">Hi %s,</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; line-height: 1.6; color: #4B5563; margin: 0 0 24px 0;">Great job adding your property to Casera! Now it's time to set up your first maintenance task and start tracking your home care.</p>
<!-- Benefits Box -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0">
<tr>
<td style="background: #F8FAFC; border-radius: 12px; padding: 24px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; font-weight: 700; color: #1a1a1a; margin: 0 0 16px 0;">Task ideas to get you started:</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#127777;&#65039; <strong>HVAC Filter Replacement</strong> - Monthly or quarterly</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#128167; <strong>Water Heater Flush</strong> - Annually</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#127807; <strong>Lawn Care</strong> - Weekly or bi-weekly</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#128374; <strong>Gutter Cleaning</strong> - Seasonal</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#128293; <strong>Smoke Detector Test</strong> - Monthly</p>
</td>
</tr>
</table>
<!-- CTA Button -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="margin: 32px 0;">
<tr>
<td style="text-align: center;">
<a href="casera://add-task" style="display: inline-block; background: linear-gradient(135deg, #0079FF 0%%, #14B8A6 100%%); color: #FFFFFF; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; font-weight: 700; text-decoration: none; padding: 16px 32px; border-radius: 12px;">Create Your First Task</a>
</td>
</tr>
</table>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #6B7280; margin: 24px 0 0 0;">Set up recurring tasks and Casera will remind you when they're due. No more forgotten maintenance!</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #6B7280; margin: 30px 0 0 0;">Best regards,<br><strong style="color: #1a1a1a;">The Casera Team</strong></p>
%s
</td>
</tr>
%s`,
emailHeader("Track Your Tasks!"),
name,
trackingPixel,
emailFooter(time.Now().Year()))
htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent)
textBody := fmt.Sprintf(`
Stay on top of home maintenance with Casera
Hi %s,
Great job adding your property to Casera! Now it's time to set up your first maintenance task and start tracking your home care.
Task ideas to get you started:
- HVAC Filter Replacement: Monthly or quarterly
- Water Heater Flush: Annually
- Lawn Care: Weekly or bi-weekly
- Gutter Cleaning: Seasonal
- Smoke Detector Test: Monthly
Set up recurring tasks and Casera will remind you when they're due. No more forgotten maintenance!
Best regards,
The Casera Team
`, name)
return s.SendEmail(to, subject, htmlBody, textBody)
}
// EmailTemplate represents an email template
type EmailTemplate struct {
name string

View File

@@ -0,0 +1,370 @@
package services
import (
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/models"
)
// OnboardingEmailService handles sending and tracking onboarding emails
type OnboardingEmailService struct {
db *gorm.DB
emailService *EmailService
baseURL string // Base URL for tracking pixel
}
// NewOnboardingEmailService creates a new onboarding email service
func NewOnboardingEmailService(db *gorm.DB, emailService *EmailService, baseURL string) *OnboardingEmailService {
return &OnboardingEmailService{
db: db,
emailService: emailService,
baseURL: baseURL,
}
}
// generateTrackingID generates a unique tracking ID for email open tracking
func generateTrackingID() string {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(bytes)
}
// HasSentEmail checks if a specific email type has already been sent to a user
func (s *OnboardingEmailService) HasSentEmail(userID uint, emailType models.OnboardingEmailType) bool {
var count int64
s.db.Model(&models.OnboardingEmail{}).
Where("user_id = ? AND email_type = ?", userID, emailType).
Count(&count)
return count > 0
}
// RecordEmailSent records that an email was sent to a user
func (s *OnboardingEmailService) RecordEmailSent(userID uint, emailType models.OnboardingEmailType, trackingID string) error {
email := &models.OnboardingEmail{
UserID: userID,
EmailType: emailType,
SentAt: time.Now().UTC(),
TrackingID: trackingID,
}
if err := s.db.Create(email).Error; err != nil {
return fmt.Errorf("failed to record email sent: %w", err)
}
return nil
}
// RecordEmailOpened records that an email was opened based on tracking ID
func (s *OnboardingEmailService) RecordEmailOpened(trackingID string) error {
now := time.Now().UTC()
result := s.db.Model(&models.OnboardingEmail{}).
Where("tracking_id = ? AND opened_at IS NULL", trackingID).
Update("opened_at", now)
if result.Error != nil {
return fmt.Errorf("failed to record email opened: %w", result.Error)
}
if result.RowsAffected > 0 {
log.Info().Str("tracking_id", trackingID).Msg("Email open recorded")
}
return nil
}
// GetEmailHistory gets all onboarding emails for a specific user
func (s *OnboardingEmailService) GetEmailHistory(userID uint) ([]models.OnboardingEmail, error) {
var emails []models.OnboardingEmail
if err := s.db.Where("user_id = ?", userID).Order("sent_at DESC").Find(&emails).Error; err != nil {
return nil, err
}
return emails, nil
}
// GetAllEmailHistory gets all onboarding emails with pagination
func (s *OnboardingEmailService) GetAllEmailHistory(page, pageSize int) ([]models.OnboardingEmail, int64, error) {
var emails []models.OnboardingEmail
var total int64
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
offset := (page - 1) * pageSize
// Count total
if err := s.db.Model(&models.OnboardingEmail{}).Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results with user info
if err := s.db.Preload("User").
Order("sent_at DESC").
Offset(offset).
Limit(pageSize).
Find(&emails).Error; err != nil {
return nil, 0, err
}
return emails, total, nil
}
// GetEmailStats returns statistics about onboarding emails
func (s *OnboardingEmailService) GetEmailStats() (*OnboardingEmailStats, error) {
stats := &OnboardingEmailStats{}
// No residence email stats
var noResTotal, noResOpened int64
s.db.Model(&models.OnboardingEmail{}).
Where("email_type = ?", models.OnboardingEmailNoResidence).
Count(&noResTotal)
s.db.Model(&models.OnboardingEmail{}).
Where("email_type = ? AND opened_at IS NOT NULL", models.OnboardingEmailNoResidence).
Count(&noResOpened)
stats.NoResidenceTotal = noResTotal
stats.NoResidenceOpened = noResOpened
// No tasks email stats
var noTasksTotal, noTasksOpened int64
s.db.Model(&models.OnboardingEmail{}).
Where("email_type = ?", models.OnboardingEmailNoTasks).
Count(&noTasksTotal)
s.db.Model(&models.OnboardingEmail{}).
Where("email_type = ? AND opened_at IS NOT NULL", models.OnboardingEmailNoTasks).
Count(&noTasksOpened)
stats.NoTasksTotal = noTasksTotal
stats.NoTasksOpened = noTasksOpened
return stats, nil
}
// OnboardingEmailStats represents statistics about onboarding emails
type OnboardingEmailStats struct {
NoResidenceTotal int64 `json:"no_residence_total"`
NoResidenceOpened int64 `json:"no_residence_opened"`
NoTasksTotal int64 `json:"no_tasks_total"`
NoTasksOpened int64 `json:"no_tasks_opened"`
}
// UsersNeedingNoResidenceEmail finds verified users who registered 2+ days ago but have no residence
func (s *OnboardingEmailService) UsersNeedingNoResidenceEmail() ([]models.User, error) {
var users []models.User
twoDaysAgo := time.Now().UTC().AddDate(0, 0, -2)
// Find users who:
// 1. Are verified
// 2. Registered 2+ days ago
// 3. Have no residences
// 4. Haven't received this email type yet
err := s.db.Raw(`
SELECT u.* FROM users u
LEFT JOIN residences r ON r.owner_id = u.id AND r.is_active = true
LEFT JOIN onboarding_emails oe ON oe.user_id = u.id AND oe.email_type = ?
WHERE u.verified = true
AND u.date_joined < ?
AND r.id IS NULL
AND oe.id IS NULL
`, models.OnboardingEmailNoResidence, twoDaysAgo).Scan(&users).Error
if err != nil {
return nil, fmt.Errorf("failed to find users needing no-residence email: %w", err)
}
return users, nil
}
// UsersNeedingNoTasksEmail finds verified users who created their first residence 5+ days ago but have no tasks
func (s *OnboardingEmailService) UsersNeedingNoTasksEmail() ([]models.User, error) {
var users []models.User
fiveDaysAgo := time.Now().UTC().AddDate(0, 0, -5)
// Find users who:
// 1. Are verified
// 2. Have at least one residence
// 3. Their first residence was created 5+ days ago
// 4. Have no tasks across ANY of their residences
// 5. Haven't received this email type yet
err := s.db.Raw(`
SELECT DISTINCT u.* FROM users u
INNER JOIN residences r ON r.owner_id = u.id AND r.is_active = true
LEFT JOIN tasks t ON t.residence_id IN (SELECT id FROM residences WHERE owner_id = u.id AND is_active = true) AND t.is_active = true
LEFT JOIN onboarding_emails oe ON oe.user_id = u.id AND oe.email_type = ?
WHERE u.verified = true
AND t.id IS NULL
AND oe.id IS NULL
AND EXISTS (
SELECT 1 FROM residences r2
WHERE r2.owner_id = u.id
AND r2.is_active = true
AND r2.created_at < ?
)
`, models.OnboardingEmailNoTasks, fiveDaysAgo).Scan(&users).Error
if err != nil {
return nil, fmt.Errorf("failed to find users needing no-tasks email: %w", err)
}
return users, nil
}
// CheckAndSendNoResidenceEmails finds eligible users and sends them the no-residence onboarding email
func (s *OnboardingEmailService) CheckAndSendNoResidenceEmails() (int, error) {
users, err := s.UsersNeedingNoResidenceEmail()
if err != nil {
return 0, err
}
sentCount := 0
for _, user := range users {
if err := s.sendNoResidenceEmail(user); err != nil {
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to send no-residence onboarding email")
continue
}
sentCount++
}
if sentCount > 0 {
log.Info().Int("count", sentCount).Msg("Sent no-residence onboarding emails")
}
return sentCount, nil
}
// CheckAndSendNoTasksEmails finds eligible users and sends them the no-tasks onboarding email
func (s *OnboardingEmailService) CheckAndSendNoTasksEmails() (int, error) {
users, err := s.UsersNeedingNoTasksEmail()
if err != nil {
return 0, err
}
sentCount := 0
for _, user := range users {
if err := s.sendNoTasksEmail(user); err != nil {
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to send no-tasks onboarding email")
continue
}
sentCount++
}
if sentCount > 0 {
log.Info().Int("count", sentCount).Msg("Sent no-tasks onboarding emails")
}
return sentCount, nil
}
// sendNoResidenceEmail sends the no-residence onboarding email to a user
func (s *OnboardingEmailService) sendNoResidenceEmail(user models.User) error {
if user.Email == "" {
return fmt.Errorf("user has no email address")
}
// Generate tracking ID
trackingID := generateTrackingID()
// Send email
if err := s.emailService.SendNoResidenceOnboardingEmail(user.Email, user.FirstName, s.baseURL, trackingID); err != nil {
return err
}
// Record that email was sent
if err := s.RecordEmailSent(user.ID, models.OnboardingEmailNoResidence, trackingID); err != nil {
// Email was sent but we failed to record it - log but don't fail
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to record no-residence email sent")
}
return nil
}
// sendNoTasksEmail sends the no-tasks onboarding email to a user
func (s *OnboardingEmailService) sendNoTasksEmail(user models.User) error {
if user.Email == "" {
return fmt.Errorf("user has no email address")
}
// Generate tracking ID
trackingID := generateTrackingID()
// Send email
if err := s.emailService.SendNoTasksOnboardingEmail(user.Email, user.FirstName, s.baseURL, trackingID); err != nil {
return err
}
// Record that email was sent
if err := s.RecordEmailSent(user.ID, models.OnboardingEmailNoTasks, trackingID); err != nil {
// Email was sent but we failed to record it - log but don't fail
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to record no-tasks email sent")
}
return nil
}
// SendOnboardingEmailToUser manually sends an onboarding email to a specific user
// This is used by admin to force-send emails regardless of eligibility criteria
func (s *OnboardingEmailService) SendOnboardingEmailToUser(userID uint, emailType models.OnboardingEmailType) error {
// Load the user
var user models.User
if err := s.db.First(&user, userID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return fmt.Errorf("user not found")
}
return fmt.Errorf("failed to load user: %w", err)
}
if user.Email == "" {
return fmt.Errorf("user has no email address")
}
// Check if already sent (for tracking purposes, we still record but warn)
alreadySent := s.HasSentEmail(userID, emailType)
// Generate tracking ID
trackingID := generateTrackingID()
// Send email based on type
var sendErr error
switch emailType {
case models.OnboardingEmailNoResidence:
sendErr = s.emailService.SendNoResidenceOnboardingEmail(user.Email, user.FirstName, s.baseURL, trackingID)
case models.OnboardingEmailNoTasks:
sendErr = s.emailService.SendNoTasksOnboardingEmail(user.Email, user.FirstName, s.baseURL, trackingID)
default:
return fmt.Errorf("unknown email type: %s", emailType)
}
if sendErr != nil {
return fmt.Errorf("failed to send email: %w", sendErr)
}
// If already sent before, delete the old record first to allow re-recording
// This allows admins to "resend" emails while still tracking them
if alreadySent {
s.db.Where("user_id = ? AND email_type = ?", userID, emailType).Delete(&models.OnboardingEmail{})
}
// Record that email was sent
if err := s.RecordEmailSent(userID, emailType, trackingID); err != nil {
log.Error().Err(err).Uint("user_id", userID).Str("email_type", string(emailType)).Msg("Failed to record onboarding email sent")
}
log.Info().
Uint("user_id", userID).
Str("email_type", string(emailType)).
Str("email", user.Email).
Bool("was_resend", alreadySent).
Msg("Onboarding email sent manually")
return nil
}

View File

@@ -18,29 +18,38 @@ import (
// Task types
const (
TypeTaskReminder = "notification:task_reminder"
TypeOverdueReminder = "notification:overdue_reminder"
TypeDailyDigest = "notification:daily_digest"
TypeSendEmail = "email:send"
TypeSendPush = "push:send"
TypeTaskReminder = "notification:task_reminder"
TypeOverdueReminder = "notification:overdue_reminder"
TypeDailyDigest = "notification:daily_digest"
TypeSendEmail = "email:send"
TypeSendPush = "push:send"
TypeOnboardingEmails = "email:onboarding"
)
// Handler handles background job processing
type Handler struct {
db *gorm.DB
pushClient *push.Client
emailService *services.EmailService
notificationService *services.NotificationService
config *config.Config
db *gorm.DB
pushClient *push.Client
emailService *services.EmailService
notificationService *services.NotificationService
onboardingService *services.OnboardingEmailService
config *config.Config
}
// NewHandler creates a new job handler
func NewHandler(db *gorm.DB, pushClient *push.Client, emailService *services.EmailService, notificationService *services.NotificationService, cfg *config.Config) *Handler {
// Create onboarding email service
var onboardingService *services.OnboardingEmailService
if emailService != nil {
onboardingService = services.NewOnboardingEmailService(db, emailService, cfg.Server.BaseURL)
}
return &Handler{
db: db,
pushClient: pushClient,
emailService: emailService,
notificationService: notificationService,
onboardingService: onboardingService,
config: cfg,
}
}
@@ -514,3 +523,41 @@ func NewSendPushTask(userID uint, title, message string, data map[string]string)
}
return asynq.NewTask(TypeSendPush, payload), nil
}
// HandleOnboardingEmails processes onboarding email campaigns
// Sends emails to:
// 1. Users who registered 2+ days ago but haven't created a residence
// 2. Users who created a residence 5+ days ago but haven't created any tasks
// Each email type is only sent once per user, ever.
func (h *Handler) HandleOnboardingEmails(ctx context.Context, task *asynq.Task) error {
log.Info().Msg("Processing onboarding emails...")
if h.onboardingService == nil {
log.Warn().Msg("Onboarding email service not configured, skipping")
return nil
}
// Send no-residence emails (users without any residences after 2 days)
noResCount, err := h.onboardingService.CheckAndSendNoResidenceEmails()
if err != nil {
log.Error().Err(err).Msg("Failed to process no-residence onboarding emails")
// Continue to next type, don't return error
} else {
log.Info().Int("count", noResCount).Msg("Sent no-residence onboarding emails")
}
// Send no-tasks emails (users with residence but no tasks after 5 days)
noTasksCount, err := h.onboardingService.CheckAndSendNoTasksEmails()
if err != nil {
log.Error().Err(err).Msg("Failed to process no-tasks onboarding emails")
} else {
log.Info().Int("count", noTasksCount).Msg("Sent no-tasks onboarding emails")
}
log.Info().
Int("no_residence_sent", noResCount).
Int("no_tasks_sent", noTasksCount).
Msg("Onboarding email processing completed")
return nil
}