...
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
#!/usr/bin/env -S v -n -w -cg -gc none -cc tcc -d use_openssl -enable-globals run
|
||||
|
||||
import freeflowuniverse.herolib.servers.mail.server
|
||||
|
||||
// Start the IMAP server on port 143
|
||||
server.start_demo() !
|
||||
@@ -1,39 +0,0 @@
|
||||
module calbox
|
||||
|
||||
// Represents an alarm/reminder
|
||||
@[heap]
|
||||
pub struct Alarm {
|
||||
pub mut:
|
||||
action AlarmAction
|
||||
trigger string // When the alarm triggers (relative or absolute)
|
||||
description string // Used for DISPLAY and EMAIL
|
||||
summary string // Used for EMAIL
|
||||
attendees []Attendee // Used for EMAIL
|
||||
attach []string // Used for AUDIO and EMAIL attachments
|
||||
}
|
||||
|
||||
// Alarm action types
|
||||
pub enum AlarmAction {
|
||||
audio // Play a sound
|
||||
display // Display a message
|
||||
email // Send an email
|
||||
}
|
||||
|
||||
// String representation of alarm action
|
||||
pub fn (a AlarmAction) str() string {
|
||||
return match a {
|
||||
.audio { 'AUDIO' }
|
||||
.display { 'DISPLAY' }
|
||||
.email { 'EMAIL' }
|
||||
}
|
||||
}
|
||||
|
||||
// Parse alarm action from string
|
||||
pub fn parse_alarm_action(s string) !AlarmAction {
|
||||
return match s {
|
||||
'AUDIO' { AlarmAction.audio }
|
||||
'DISPLAY' { AlarmAction.display }
|
||||
'EMAIL' { AlarmAction.email }
|
||||
else { error('Invalid alarm action: ${s}') }
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
module calbox
|
||||
|
||||
// Represents a calendar attendee
|
||||
@[heap]
|
||||
pub struct Attendee {
|
||||
pub mut:
|
||||
email string
|
||||
name string
|
||||
role AttendeeRole
|
||||
partstat AttendeePartStat
|
||||
rsvp bool
|
||||
delegated_to []string
|
||||
delegated_from []string
|
||||
}
|
||||
|
||||
// Attendee role types
|
||||
pub enum AttendeeRole {
|
||||
chair // Meeting chair/organizer
|
||||
req_participant // Required participant
|
||||
opt_participant // Optional participant
|
||||
non_participant // Non-participant (e.g. room, resource)
|
||||
}
|
||||
|
||||
// String representation of attendee role
|
||||
pub fn (r AttendeeRole) str() string {
|
||||
return match r {
|
||||
.chair { 'CHAIR' }
|
||||
.req_participant { 'REQ-PARTICIPANT' }
|
||||
.opt_participant { 'OPT-PARTICIPANT' }
|
||||
.non_participant { 'NON-PARTICIPANT' }
|
||||
}
|
||||
}
|
||||
|
||||
// Parse attendee role from string
|
||||
pub fn parse_attendee_role(s string) !AttendeeRole {
|
||||
return match s {
|
||||
'CHAIR' { AttendeeRole.chair }
|
||||
'REQ-PARTICIPANT' { AttendeeRole.req_participant }
|
||||
'OPT-PARTICIPANT' { AttendeeRole.opt_participant }
|
||||
'NON-PARTICIPANT' { AttendeeRole.non_participant }
|
||||
else { error('Invalid attendee role: ${s}') }
|
||||
}
|
||||
}
|
||||
|
||||
// Attendee participation status
|
||||
pub enum AttendeePartStat {
|
||||
needs_action // No response yet
|
||||
accepted // Accepted invitation
|
||||
declined // Declined invitation
|
||||
tentative // Tentatively accepted
|
||||
delegated // Delegated to another
|
||||
}
|
||||
|
||||
// String representation of participation status
|
||||
pub fn (p AttendeePartStat) str() string {
|
||||
return match p {
|
||||
.needs_action { 'NEEDS-ACTION' }
|
||||
.accepted { 'ACCEPTED' }
|
||||
.declined { 'DECLINED' }
|
||||
.tentative { 'TENTATIVE' }
|
||||
.delegated { 'DELEGATED' }
|
||||
}
|
||||
}
|
||||
|
||||
// Parse participation status from string
|
||||
pub fn parse_attendee_partstat(s string) !AttendeePartStat {
|
||||
return match s {
|
||||
'NEEDS-ACTION' { AttendeePartStat.needs_action }
|
||||
'ACCEPTED' { AttendeePartStat.accepted }
|
||||
'DECLINED' { AttendeePartStat.declined }
|
||||
'TENTATIVE' { AttendeePartStat.tentative }
|
||||
'DELEGATED' { AttendeePartStat.delegated }
|
||||
else { error('Invalid participation status: ${s}') }
|
||||
}
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
module calbox
|
||||
|
||||
// Represents a calendar collection
|
||||
@[heap]
|
||||
pub struct Calendar {
|
||||
mut:
|
||||
name string
|
||||
objects []CalendarItem
|
||||
description string
|
||||
timezone string // Calendar timezone as iCalendar VTIMEZONE
|
||||
read_only bool // Whether calendar is read-only
|
||||
|
||||
// Properties from CalDAV spec
|
||||
supported_components []ComponentType // e.g. [.vevent, .vtodo]
|
||||
min_date_time string // Earliest date allowed
|
||||
max_date_time string // Latest date allowed
|
||||
max_instances int // Max recurrence instances
|
||||
max_attendees int // Max attendees per instance
|
||||
max_resource_size int // Max size of calendar object
|
||||
}
|
||||
|
||||
// Creates a new calendar collection
|
||||
pub fn new(name string) &Calendar {
|
||||
return &Calendar{
|
||||
name: name
|
||||
objects: []CalendarItem{}
|
||||
supported_components: [ComponentType.vevent, ComponentType.vtodo, ComponentType.vjournal]
|
||||
min_date_time: '19000101T000000Z'
|
||||
max_date_time: '20491231T235959Z'
|
||||
max_instances: 1000
|
||||
max_attendees: 100
|
||||
max_resource_size: 1024 * 1024 // 1MB
|
||||
}
|
||||
}
|
||||
|
||||
// Returns all calendar objects
|
||||
pub fn (mut self Calendar) list() ![]CalendarItem {
|
||||
return self.objects
|
||||
}
|
||||
|
||||
// Gets a calendar object by UID
|
||||
pub fn (mut self Calendar) get_by_uid(uid string) ?CalendarItem {
|
||||
for obj in self.objects {
|
||||
match obj.comp_type {
|
||||
.vevent {
|
||||
if obj.event?.uid == uid {
|
||||
return obj
|
||||
}
|
||||
}
|
||||
.vtodo {
|
||||
if obj.todo?.uid == uid {
|
||||
return obj
|
||||
}
|
||||
}
|
||||
.vjournal {
|
||||
if obj.journal?.uid == uid {
|
||||
return obj
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return none
|
||||
}
|
||||
|
||||
// Deletes a calendar object by UID
|
||||
pub fn (mut self Calendar) delete(uid string) ! {
|
||||
if self.read_only {
|
||||
return error('Calendar is read-only')
|
||||
}
|
||||
|
||||
for i, obj in self.objects {
|
||||
mut found := false
|
||||
match obj.comp_type {
|
||||
.vevent {
|
||||
if event := obj.event {
|
||||
found = event.uid == uid
|
||||
}
|
||||
}
|
||||
.vtodo {
|
||||
if todo := obj.todo {
|
||||
found = todo.uid == uid
|
||||
}
|
||||
}
|
||||
.vjournal {
|
||||
if journal := obj.journal {
|
||||
found = journal.uid == uid
|
||||
}
|
||||
}
|
||||
}
|
||||
if found {
|
||||
self.objects.delete(i)
|
||||
return
|
||||
}
|
||||
}
|
||||
return error('Calendar object with UID ${uid} not found')
|
||||
}
|
||||
|
||||
// Validates a calendar object
|
||||
fn (mut self Calendar) validate(obj CalendarItem) ! {
|
||||
// Validate component type is supported
|
||||
if obj.comp_type !in self.supported_components {
|
||||
return error('Calendar component type ${obj.comp_type} not supported')
|
||||
}
|
||||
|
||||
// Validate based on component type
|
||||
match obj.comp_type {
|
||||
.vevent {
|
||||
event := obj.event or { return error('VEVENT component missing') }
|
||||
|
||||
// Validate required fields
|
||||
if event.uid.len == 0 {
|
||||
return error('UID is required')
|
||||
}
|
||||
if event.start_time == 0 {
|
||||
return error('Start time is required')
|
||||
}
|
||||
if event.end_time == none && event.duration == none {
|
||||
return error('Either end time or duration is required')
|
||||
}
|
||||
|
||||
// Validate attendees count
|
||||
if event.attendees.len > self.max_attendees {
|
||||
return error('Exceeds maximum attendees limit of ${self.max_attendees}')
|
||||
}
|
||||
|
||||
// Validate recurrence
|
||||
if event.rrule != none {
|
||||
// TODO: Validate max instances once recurrence expansion is implemented
|
||||
}
|
||||
}
|
||||
.vtodo {
|
||||
todo := obj.todo or { return error('VTODO component missing') }
|
||||
|
||||
// Validate required fields
|
||||
if todo.uid.len == 0 {
|
||||
return error('UID is required')
|
||||
}
|
||||
|
||||
// Validate attendees count
|
||||
if todo.attendees.len > self.max_attendees {
|
||||
return error('Exceeds maximum attendees limit of ${self.max_attendees}')
|
||||
}
|
||||
}
|
||||
.vjournal {
|
||||
journal := obj.journal or { return error('VJOURNAL component missing') }
|
||||
|
||||
// Validate required fields
|
||||
if journal.uid.len == 0 {
|
||||
return error('UID is required')
|
||||
}
|
||||
if journal.start_time == 0 {
|
||||
return error('Start time is required')
|
||||
}
|
||||
|
||||
// Validate attendees count
|
||||
if journal.attendees.len > self.max_attendees {
|
||||
return error('Exceeds maximum attendees limit of ${self.max_attendees}')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adds or updates a calendar object
|
||||
pub fn (mut self Calendar) put(obj CalendarItem) ! {
|
||||
if self.read_only {
|
||||
return error('Calendar is read-only')
|
||||
}
|
||||
|
||||
// Validate the object
|
||||
self.validate(obj) or { return err }
|
||||
|
||||
mut found := false
|
||||
for i, existing in self.objects {
|
||||
mut match_uid := false
|
||||
match obj.comp_type {
|
||||
.vevent {
|
||||
if e1 := obj.event {
|
||||
if e2 := existing.event {
|
||||
match_uid = e1.uid == e2.uid
|
||||
}
|
||||
}
|
||||
}
|
||||
.vtodo {
|
||||
if t1 := obj.todo {
|
||||
if t2 := existing.todo {
|
||||
match_uid = t1.uid == t2.uid
|
||||
}
|
||||
}
|
||||
}
|
||||
.vjournal {
|
||||
if j1 := obj.journal {
|
||||
if j2 := existing.journal {
|
||||
match_uid = j1.uid == j2.uid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if match_uid {
|
||||
self.objects[i] = obj
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
self.objects << obj
|
||||
}
|
||||
}
|
||||
|
||||
// Checks if a journal entry overlaps with a time range
|
||||
fn (journal Journal) overlaps(tr TimeRange) bool {
|
||||
return is_in_range(journal.start_time, tr)
|
||||
}
|
||||
|
||||
// Finds calendar objects in the given time range
|
||||
pub fn (mut self Calendar) find_by_time(tr TimeRange) ![]CalendarItem {
|
||||
mut results := []CalendarItem{}
|
||||
|
||||
for obj in self.objects {
|
||||
match obj.comp_type {
|
||||
.vevent {
|
||||
if event := obj.event {
|
||||
// Get all instances in the time range
|
||||
instances := event.get_instances(tr)!
|
||||
if instances.len > 0 {
|
||||
results << obj
|
||||
}
|
||||
}
|
||||
}
|
||||
.vtodo {
|
||||
if todo := obj.todo {
|
||||
// Check todo timing
|
||||
mut overlaps := false
|
||||
|
||||
// Check start time if set
|
||||
if start := todo.start_time {
|
||||
if is_in_range(start, tr) {
|
||||
overlaps = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check due time if set
|
||||
if due := todo.due_time {
|
||||
if is_in_range(due, tr) {
|
||||
overlaps = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check completed time if set
|
||||
if completed := todo.completed {
|
||||
if is_in_range(completed, tr) {
|
||||
overlaps = true
|
||||
}
|
||||
}
|
||||
|
||||
// If no timing info, include if created in range
|
||||
if todo.start_time == none && todo.due_time == none && todo.completed == none {
|
||||
if is_in_range(todo.created, tr) {
|
||||
overlaps = true
|
||||
}
|
||||
}
|
||||
|
||||
if overlaps {
|
||||
results << obj
|
||||
}
|
||||
}
|
||||
}
|
||||
.vjournal {
|
||||
if journal := obj.journal {
|
||||
// Journal entries are point-in-time
|
||||
if is_in_range(journal.start_time, tr) {
|
||||
results << obj
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Gets all instances of calendar objects in a time range
|
||||
pub fn (mut self Calendar) get_instances(tr TimeRange) ![]EventInstance {
|
||||
mut instances := []EventInstance{}
|
||||
|
||||
for obj in self.objects {
|
||||
if obj.comp_type == .vevent {
|
||||
if event := obj.event {
|
||||
event_instances := event.get_instances(tr)!
|
||||
instances << event_instances
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by start time
|
||||
instances.sort(a.start_time < b.start_time)
|
||||
|
||||
return instances
|
||||
}
|
||||
|
||||
// Gets free/busy time in a given range
|
||||
pub fn (mut self Calendar) get_freebusy(tr TimeRange) ![]TimeRange {
|
||||
mut busy_ranges := []TimeRange{}
|
||||
|
||||
// Get all event instances in the range
|
||||
instances := self.get_instances(tr)!
|
||||
|
||||
// Convert instances to busy time ranges
|
||||
for instance in instances {
|
||||
// Skip transparent events
|
||||
if instance.original_event.transp == .transparent {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip cancelled events
|
||||
if instance.original_event.status == .cancelled {
|
||||
continue
|
||||
}
|
||||
|
||||
busy_ranges << TimeRange{
|
||||
start: instance.start_time
|
||||
end: instance.end_time
|
||||
}
|
||||
}
|
||||
|
||||
// Merge overlapping ranges
|
||||
if busy_ranges.len > 0 {
|
||||
busy_ranges.sort(a.start < b.start)
|
||||
mut merged := []TimeRange{}
|
||||
mut current := busy_ranges[0]
|
||||
|
||||
for i := 1; i < busy_ranges.len; i++ {
|
||||
if busy_ranges[i].start <= current.end {
|
||||
// Ranges overlap, extend current range
|
||||
if busy_ranges[i].end > current.end {
|
||||
current.end = busy_ranges[i].end
|
||||
}
|
||||
} else {
|
||||
// No overlap, start new range
|
||||
merged << current
|
||||
current = busy_ranges[i]
|
||||
}
|
||||
}
|
||||
merged << current
|
||||
return merged
|
||||
}
|
||||
|
||||
return busy_ranges
|
||||
}
|
||||
|
||||
// Returns number of calendar objects
|
||||
pub fn (mut self Calendar) len() int {
|
||||
return self.objects.len
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
module calbox
|
||||
|
||||
// Base calendar component fields
|
||||
@[heap]
|
||||
pub struct CalendarComponent {
|
||||
pub mut:
|
||||
uid string
|
||||
etag string // Entity tag for change tracking
|
||||
created i64 // Creation timestamp
|
||||
modified i64 // Last modified timestamp
|
||||
summary string
|
||||
description string
|
||||
categories []string
|
||||
status ComponentStatus
|
||||
class ComponentClass
|
||||
url string
|
||||
location string
|
||||
geo ?GeoLocation
|
||||
alarms []Alarm
|
||||
}
|
||||
|
||||
// Geographic location
|
||||
pub struct GeoLocation {
|
||||
pub mut:
|
||||
latitude f64
|
||||
longitude f64
|
||||
}
|
||||
|
||||
// Calendar component status
|
||||
pub enum ComponentStatus {
|
||||
tentative // Tentatively scheduled
|
||||
confirmed // Confirmed
|
||||
cancelled // Cancelled/deleted
|
||||
needs_action // Todo needs action
|
||||
completed // Todo completed
|
||||
in_process // Todo in progress
|
||||
draft // Journal draft
|
||||
final // Journal final
|
||||
}
|
||||
|
||||
// String representation of component status
|
||||
pub fn (s ComponentStatus) str() string {
|
||||
return match s {
|
||||
.tentative { 'TENTATIVE' }
|
||||
.confirmed { 'CONFIRMED' }
|
||||
.cancelled { 'CANCELLED' }
|
||||
.needs_action { 'NEEDS-ACTION' }
|
||||
.completed { 'COMPLETED' }
|
||||
.in_process { 'IN-PROCESS' }
|
||||
.draft { 'DRAFT' }
|
||||
.final { 'FINAL' }
|
||||
}
|
||||
}
|
||||
|
||||
// Parse component status from string
|
||||
pub fn parse_component_status(s string) !ComponentStatus {
|
||||
return match s {
|
||||
'TENTATIVE' { ComponentStatus.tentative }
|
||||
'CONFIRMED' { ComponentStatus.confirmed }
|
||||
'CANCELLED' { ComponentStatus.cancelled }
|
||||
'NEEDS-ACTION' { ComponentStatus.needs_action }
|
||||
'COMPLETED' { ComponentStatus.completed }
|
||||
'IN-PROCESS' { ComponentStatus.in_process }
|
||||
'DRAFT' { ComponentStatus.draft }
|
||||
'FINAL' { ComponentStatus.final }
|
||||
else { error('Invalid component status: ${s}') }
|
||||
}
|
||||
}
|
||||
|
||||
// Calendar component class (visibility/privacy)
|
||||
pub enum ComponentClass {
|
||||
public // Visible to everyone
|
||||
private // Only visible to owner
|
||||
confidential // Limited visibility
|
||||
}
|
||||
|
||||
// String representation of component class
|
||||
pub fn (c ComponentClass) str() string {
|
||||
return match c {
|
||||
.public { 'PUBLIC' }
|
||||
.private { 'PRIVATE' }
|
||||
.confidential { 'CONFIDENTIAL' }
|
||||
}
|
||||
}
|
||||
|
||||
// Parse component class from string
|
||||
pub fn parse_component_class(s string) !ComponentClass {
|
||||
return match s {
|
||||
'PUBLIC' { ComponentClass.public }
|
||||
'PRIVATE' { ComponentClass.private }
|
||||
'CONFIDENTIAL' { ComponentClass.confidential }
|
||||
else { error('Invalid component class: ${s}') }
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
module calbox
|
||||
|
||||
// Represents a calendar object resource (event, todo, journal)
|
||||
@[heap]
|
||||
pub struct CalendarItem {
|
||||
pub mut:
|
||||
comp_type ComponentType // Type of calendar component : vevent, vtodo, vjournal
|
||||
event ?Event // Set if comp_type is .vevent
|
||||
todo ?Todo // Set if comp_type is .vtodo
|
||||
journal ?Journal // Set if comp_type is .vjournal
|
||||
}
|
||||
|
||||
// Component type enum
|
||||
pub enum ComponentType {
|
||||
vevent
|
||||
vtodo
|
||||
vjournal
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
module calbox
|
||||
|
||||
fn test_create_event() {
|
||||
mut cal := new('test_calendar')
|
||||
|
||||
// Create an event with all fields
|
||||
event := Event{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'event1@example.com'
|
||||
etag: '"1"'
|
||||
created: 1708070400 // 2024-02-16 09:00:00 UTC
|
||||
modified: 1708070400
|
||||
summary: 'Team Meeting'
|
||||
description: 'Weekly team sync'
|
||||
categories: ['Work', 'Meeting']
|
||||
status: .confirmed
|
||||
class: .public
|
||||
location: 'Conference Room'
|
||||
alarms: [
|
||||
Alarm{
|
||||
action: .display
|
||||
trigger: '-PT15M'
|
||||
description: 'Meeting starts in 15 minutes'
|
||||
},
|
||||
]
|
||||
}
|
||||
start_time: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
end_time: 1708077600 // 2024-02-16 11:00:00 UTC
|
||||
transp: .opaque
|
||||
attendees: [
|
||||
Attendee{
|
||||
email: 'john@example.com'
|
||||
name: 'John Doe'
|
||||
role: .req_participant
|
||||
partstat: .accepted
|
||||
rsvp: true
|
||||
},
|
||||
Attendee{
|
||||
email: 'jane@example.com'
|
||||
name: 'Jane Smith'
|
||||
role: .req_participant
|
||||
partstat: .needs_action
|
||||
rsvp: true
|
||||
},
|
||||
]
|
||||
organizer: Attendee{
|
||||
email: 'boss@example.com'
|
||||
name: 'The Boss'
|
||||
role: .chair
|
||||
partstat: .accepted
|
||||
}
|
||||
}
|
||||
|
||||
obj := CalendarItem{
|
||||
comp_type: .vevent
|
||||
event: event
|
||||
}
|
||||
|
||||
cal.put(obj) or { panic(err) }
|
||||
assert cal.len() == 1
|
||||
|
||||
// Verify retrieval
|
||||
found := cal.get_by_uid('event1@example.com') or { panic(err) }
|
||||
assert found.comp_type == .vevent
|
||||
|
||||
if e := found.event {
|
||||
assert e.summary == 'Team Meeting'
|
||||
assert e.start_time == 1708074000
|
||||
assert e.end_time? == 1708077600
|
||||
assert e.attendees.len == 2
|
||||
assert e.organizer?.email == 'boss@example.com'
|
||||
} else {
|
||||
assert false, 'Event not found'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_create_recurring_event() {
|
||||
mut cal := new('test_calendar')
|
||||
|
||||
// Create a daily recurring event
|
||||
event := Event{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'recurring@example.com'
|
||||
etag: '"1"'
|
||||
created: 1708070400
|
||||
modified: 1708070400
|
||||
summary: 'Daily Standup'
|
||||
}
|
||||
start_time: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
duration: 'PT30M' // 30 minutes
|
||||
rrule: RecurrenceRule{
|
||||
frequency: .daily
|
||||
interval: 1
|
||||
count: 5
|
||||
}
|
||||
}
|
||||
|
||||
obj := CalendarItem{
|
||||
comp_type: .vevent
|
||||
event: event
|
||||
}
|
||||
|
||||
cal.put(obj) or { panic(err) }
|
||||
|
||||
// Test time range search
|
||||
tr := TimeRange{
|
||||
start: 1708160400 // 2024-02-17 10:00:00 UTC
|
||||
end: 1708333200 // 2024-02-19 10:00:00 UTC
|
||||
}
|
||||
|
||||
results := cal.find_by_time(tr) or { panic(err) }
|
||||
assert results.len == 1 // Should find the recurring event
|
||||
}
|
||||
|
||||
fn test_create_todo() {
|
||||
mut cal := new('test_calendar')
|
||||
|
||||
// Create a todo with due date
|
||||
todo := Todo{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'todo1@example.com'
|
||||
etag: '"1"'
|
||||
created: 1708070400
|
||||
modified: 1708070400
|
||||
summary: 'Write Documentation'
|
||||
status: .needs_action
|
||||
}
|
||||
due_time: 1708160400 // 2024-02-17 10:00:00 UTC
|
||||
percent: 0
|
||||
}
|
||||
|
||||
obj := CalendarItem{
|
||||
comp_type: .vtodo
|
||||
todo: todo
|
||||
}
|
||||
|
||||
cal.put(obj) or { panic(err) }
|
||||
|
||||
// Test completion
|
||||
mut updated_todo := todo
|
||||
updated_todo.status = .completed
|
||||
updated_todo.completed = 1708074000
|
||||
updated_todo.percent = 100
|
||||
|
||||
updated_obj := CalendarItem{
|
||||
comp_type: .vtodo
|
||||
todo: updated_todo
|
||||
}
|
||||
|
||||
cal.put(updated_obj) or { panic(err) }
|
||||
|
||||
// Verify update
|
||||
found := cal.get_by_uid('todo1@example.com') or { panic(err) }
|
||||
if t := found.todo {
|
||||
assert t.status == .completed
|
||||
assert t.completed? == 1708074000
|
||||
assert t.percent? == 100
|
||||
} else {
|
||||
assert false, 'Todo not found'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_create_journal() {
|
||||
mut cal := new('test_calendar')
|
||||
|
||||
// Create a journal entry
|
||||
journal := Journal{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'journal1@example.com'
|
||||
etag: '"1"'
|
||||
created: 1708070400
|
||||
modified: 1708070400
|
||||
summary: 'Project Notes'
|
||||
description: 'Today we discussed the new features...'
|
||||
categories: ['Work', 'Notes']
|
||||
}
|
||||
start_time: 1708070400 // 2024-02-16 09:00:00 UTC
|
||||
}
|
||||
|
||||
obj := CalendarItem{
|
||||
comp_type: .vjournal
|
||||
journal: journal
|
||||
}
|
||||
|
||||
cal.put(obj) or { panic(err) }
|
||||
assert cal.len() == 1
|
||||
|
||||
// Test time range search
|
||||
tr := TimeRange{
|
||||
start: 1708070400 // 2024-02-16 09:00:00 UTC
|
||||
end: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
}
|
||||
|
||||
results := cal.find_by_time(tr) or { panic(err) }
|
||||
assert results.len == 1
|
||||
}
|
||||
|
||||
fn test_validation() {
|
||||
mut cal := new('test_calendar')
|
||||
|
||||
// Test invalid component type
|
||||
invalid_type := CalendarItem{
|
||||
comp_type: .vevent // Using a valid type but with invalid data
|
||||
}
|
||||
if _ := cal.put(invalid_type) {
|
||||
assert false, 'Should reject invalid component type'
|
||||
}
|
||||
|
||||
// Test missing required fields
|
||||
invalid_event := CalendarItem{
|
||||
comp_type: .vevent
|
||||
event: Event{}
|
||||
}
|
||||
if _ := cal.put(invalid_event) {
|
||||
assert false, 'Should reject event without required fields'
|
||||
}
|
||||
|
||||
// Test too many attendees
|
||||
mut many_attendees := []Attendee{cap: 200}
|
||||
for i in 0 .. 200 {
|
||||
many_attendees << Attendee{
|
||||
email: 'user${i}@example.com'
|
||||
name: 'User ${i}'
|
||||
}
|
||||
}
|
||||
|
||||
event_many_attendees := CalendarItem{
|
||||
comp_type: .vevent
|
||||
event: Event{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'many@example.com'
|
||||
}
|
||||
start_time: 1708070400
|
||||
end_time: 1708074000
|
||||
attendees: many_attendees
|
||||
}
|
||||
}
|
||||
if _ := cal.put(event_many_attendees) {
|
||||
assert false, 'Should reject event with too many attendees'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_read_only() {
|
||||
mut cal := new('test_calendar')
|
||||
cal.read_only = true
|
||||
|
||||
event := CalendarItem{
|
||||
comp_type: .vevent
|
||||
event: Event{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'test@example.com'
|
||||
}
|
||||
start_time: 1708070400
|
||||
end_time: 1708074000
|
||||
}
|
||||
}
|
||||
|
||||
// Test put
|
||||
if _ := cal.put(event) {
|
||||
assert false, 'Should reject put on read-only calendar'
|
||||
}
|
||||
|
||||
// Test delete
|
||||
if _ := cal.delete('test@example.com') {
|
||||
assert false, 'Should reject delete on read-only calendar'
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
module calbox
|
||||
|
||||
import time
|
||||
|
||||
// Converts a timestamp to iCalendar UTC date-time format (YYYYMMDDTHHMMSSZ)
|
||||
pub fn format_datetime_utc(ts i64) string {
|
||||
t := time.unix(ts)
|
||||
return t.strftime('%Y%m%dT%H%M%SZ')
|
||||
}
|
||||
|
||||
// Converts a timestamp to iCalendar date format (YYYYMMDD)
|
||||
pub fn format_date(ts i64) string {
|
||||
t := time.unix(ts)
|
||||
return t.strftime('%Y%m%d')
|
||||
}
|
||||
|
||||
// Parses an iCalendar date-time string to timestamp
|
||||
pub fn parse_datetime(dt string) !i64 {
|
||||
if dt.len < 8 {
|
||||
return error('Invalid date-time format')
|
||||
}
|
||||
|
||||
// Parse date part (YYYYMMDD)
|
||||
year := dt[0..4].int()
|
||||
month := dt[4..6].int()
|
||||
day := dt[6..8].int()
|
||||
|
||||
mut hour := 0
|
||||
mut min := 0
|
||||
mut sec := 0
|
||||
mut is_utc := false
|
||||
|
||||
// Parse time part if present (THHMMSS[Z])
|
||||
if dt.len > 8 {
|
||||
if dt[8] != `T` {
|
||||
return error('Invalid date-time format: missing T separator')
|
||||
}
|
||||
if dt.len < 15 {
|
||||
return error('Invalid date-time format: incomplete time')
|
||||
}
|
||||
|
||||
hour = dt[9..11].int()
|
||||
min = dt[11..13].int()
|
||||
sec = dt[13..15].int()
|
||||
|
||||
is_utc = dt.ends_with('Z')
|
||||
}
|
||||
|
||||
// Create time.Time
|
||||
mut t := time.new(
|
||||
year: year
|
||||
month: month
|
||||
day: day
|
||||
hour: hour
|
||||
minute: min
|
||||
second: sec
|
||||
)
|
||||
|
||||
// Convert to UTC if needed
|
||||
if !is_utc {
|
||||
// TODO: Handle local time conversion
|
||||
// For now assume UTC
|
||||
}
|
||||
|
||||
return t.unix()
|
||||
}
|
||||
|
||||
// Parses an iCalendar date string to timestamp
|
||||
pub fn parse_date(d string) !i64 {
|
||||
if d.len != 8 {
|
||||
return error('Invalid date format: must be YYYYMMDD')
|
||||
}
|
||||
|
||||
year := d[0..4].int()
|
||||
month := d[4..6].int()
|
||||
day := d[6..8].int()
|
||||
|
||||
t := time.new(
|
||||
year: year
|
||||
month: month
|
||||
day: day
|
||||
hour: 0
|
||||
minute: 0
|
||||
second: 0
|
||||
)
|
||||
|
||||
return t.unix()
|
||||
}
|
||||
|
||||
// Parses a date or date-time string
|
||||
pub fn parse_date_or_datetime(value string) !i64 {
|
||||
if value.contains('T') {
|
||||
return parse_datetime(value)!
|
||||
}
|
||||
return parse_date(value)!
|
||||
}
|
||||
|
||||
// Formats a timestamp as either date or date-time based on has_time flag
|
||||
pub fn format_date_or_datetime(ts i64, has_time bool) string {
|
||||
if has_time {
|
||||
return format_datetime_utc(ts)
|
||||
}
|
||||
return format_date(ts)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
module calbox
|
||||
|
||||
fn test_format_datetime_utc() {
|
||||
// Test specific timestamp: 2024-02-16 10:00:00 UTC
|
||||
ts := i64(1708074000)
|
||||
formatted := format_datetime_utc(ts)
|
||||
assert formatted == '20240216T100000Z'
|
||||
|
||||
// Test midnight
|
||||
midnight := i64(1708041600) // 2024-02-16 00:00:00 UTC
|
||||
midnight_formatted := format_datetime_utc(midnight)
|
||||
assert midnight_formatted == '20240216T000000Z'
|
||||
}
|
||||
|
||||
fn test_format_date() {
|
||||
// Test specific date
|
||||
ts := i64(1708074000) // 2024-02-16 10:00:00 UTC
|
||||
formatted := format_date(ts)
|
||||
assert formatted == '20240216'
|
||||
|
||||
// Time part should be ignored
|
||||
later := i64(1708117200) // 2024-02-16 22:00:00 UTC
|
||||
later_formatted := format_date(later)
|
||||
assert later_formatted == '20240216'
|
||||
}
|
||||
|
||||
fn test_parse_datetime() {
|
||||
// Test UTC date-time
|
||||
ts := parse_datetime('20240216T100000Z')!
|
||||
assert ts == 1708074000
|
||||
|
||||
// Test date-time without seconds
|
||||
no_seconds := parse_datetime('20240216T1000')!
|
||||
assert no_seconds == 1708074000
|
||||
|
||||
// Test invalid formats
|
||||
if _ := parse_datetime('invalid') {
|
||||
assert false, 'Should reject invalid format'
|
||||
}
|
||||
if _ := parse_datetime('20240216') {
|
||||
assert false, 'Should reject date without time'
|
||||
}
|
||||
if _ := parse_datetime('20240216T') {
|
||||
assert false, 'Should reject incomplete time'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_parse_date() {
|
||||
// Test basic date
|
||||
ts := parse_date('20240216')!
|
||||
assert ts == 1708041600 // 2024-02-16 00:00:00 UTC
|
||||
|
||||
// Test invalid formats
|
||||
if _ := parse_date('2024021') {
|
||||
assert false, 'Should reject too short date'
|
||||
}
|
||||
if _ := parse_date('202402166') {
|
||||
assert false, 'Should reject too long date'
|
||||
}
|
||||
if _ := parse_date('20240216T100000Z') {
|
||||
assert false, 'Should reject date-time format'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_parse_date_or_datetime() {
|
||||
// Test date format
|
||||
date_ts := parse_date_or_datetime('20240216')!
|
||||
assert date_ts == 1708041600 // 2024-02-16 00:00:00 UTC
|
||||
|
||||
// Test date-time format
|
||||
datetime_ts := parse_date_or_datetime('20240216T100000Z')!
|
||||
assert datetime_ts == 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
|
||||
// Test invalid formats
|
||||
if _ := parse_date_or_datetime('invalid') {
|
||||
assert false, 'Should reject invalid format'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_format_date_or_datetime() {
|
||||
ts := i64(1708074000) // 2024-02-16 10:00:00 UTC
|
||||
|
||||
// Test as date-time
|
||||
datetime := format_date_or_datetime(ts, true)
|
||||
assert datetime == '20240216T100000Z'
|
||||
|
||||
// Test as date
|
||||
date := format_date_or_datetime(ts, false)
|
||||
assert date == '20240216'
|
||||
}
|
||||
|
||||
fn test_roundtrip() {
|
||||
// Test date-time roundtrip
|
||||
original_ts := i64(1708074000)
|
||||
formatted := format_datetime_utc(original_ts)
|
||||
parsed_ts := parse_datetime(formatted)!
|
||||
assert parsed_ts == original_ts
|
||||
|
||||
// Test date roundtrip
|
||||
date_formatted := format_date(original_ts)
|
||||
date_ts := parse_date(date_formatted)!
|
||||
assert date_ts == i64(1708041600) // Should be start of day
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
module calbox
|
||||
|
||||
// Represents a duration in seconds
|
||||
pub struct Duration {
|
||||
pub:
|
||||
seconds i64
|
||||
}
|
||||
|
||||
// Parses an ISO 8601 duration string (e.g. PT1H30M)
|
||||
pub fn parse_duration(iso_duration string) !Duration {
|
||||
if iso_duration.len < 2 || !iso_duration.starts_with('P') {
|
||||
return error('Invalid duration format: must start with P')
|
||||
}
|
||||
|
||||
mut seconds := i64(0)
|
||||
mut number_str := ''
|
||||
mut time_part := false
|
||||
|
||||
for i := 1; i < iso_duration.len; i++ {
|
||||
c := iso_duration[i]
|
||||
match c {
|
||||
`T` {
|
||||
if time_part {
|
||||
return error('Invalid duration format: duplicate T')
|
||||
}
|
||||
time_part = true
|
||||
}
|
||||
`0`...`9` {
|
||||
number_str += c.ascii_str()
|
||||
}
|
||||
`Y` {
|
||||
if time_part {
|
||||
return error('Invalid duration format: Y in time part')
|
||||
}
|
||||
if number_str == '' {
|
||||
return error('Invalid duration format: missing number before Y')
|
||||
}
|
||||
years := number_str.i64()
|
||||
seconds += years * 365 * 24 * 60 * 60 // Approximate
|
||||
number_str = ''
|
||||
}
|
||||
`M` {
|
||||
if number_str == '' {
|
||||
return error('Invalid duration format: missing number before M')
|
||||
}
|
||||
if time_part {
|
||||
// Minutes
|
||||
minutes := number_str.i64()
|
||||
seconds += minutes * 60
|
||||
} else {
|
||||
// Months
|
||||
months := number_str.i64()
|
||||
seconds += months * 30 * 24 * 60 * 60 // Approximate
|
||||
}
|
||||
number_str = ''
|
||||
}
|
||||
`W` {
|
||||
if time_part {
|
||||
return error('Invalid duration format: W in time part')
|
||||
}
|
||||
if number_str == '' {
|
||||
return error('Invalid duration format: missing number before W')
|
||||
}
|
||||
weeks := number_str.i64()
|
||||
seconds += weeks * 7 * 24 * 60 * 60
|
||||
number_str = ''
|
||||
}
|
||||
`D` {
|
||||
if time_part {
|
||||
return error('Invalid duration format: D in time part')
|
||||
}
|
||||
if number_str == '' {
|
||||
return error('Invalid duration format: missing number before D')
|
||||
}
|
||||
days := number_str.i64()
|
||||
seconds += days * 24 * 60 * 60
|
||||
number_str = ''
|
||||
}
|
||||
`H` {
|
||||
if !time_part {
|
||||
return error('Invalid duration format: H in date part')
|
||||
}
|
||||
if number_str == '' {
|
||||
return error('Invalid duration format: missing number before H')
|
||||
}
|
||||
hours := number_str.i64()
|
||||
seconds += hours * 60 * 60
|
||||
number_str = ''
|
||||
}
|
||||
`S` {
|
||||
if !time_part {
|
||||
return error('Invalid duration format: S in date part')
|
||||
}
|
||||
if number_str == '' {
|
||||
return error('Invalid duration format: missing number before S')
|
||||
}
|
||||
seconds += number_str.i64()
|
||||
number_str = ''
|
||||
}
|
||||
else {
|
||||
return error('Invalid duration format: unknown character ${c}')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if number_str != '' {
|
||||
return error('Invalid duration format: number without unit')
|
||||
}
|
||||
|
||||
return Duration{seconds}
|
||||
}
|
||||
|
||||
// Formats the duration as an ISO 8601 duration string
|
||||
pub fn (d Duration) str() string {
|
||||
mut s := 'P'
|
||||
mut remaining := d.seconds
|
||||
|
||||
// Years (approximate)
|
||||
years := remaining / (365 * 24 * 60 * 60)
|
||||
if years > 0 {
|
||||
s += '${years}Y'
|
||||
remaining = remaining % (365 * 24 * 60 * 60)
|
||||
}
|
||||
|
||||
// Days
|
||||
days := remaining / (24 * 60 * 60)
|
||||
if days > 0 {
|
||||
s += '${days}D'
|
||||
remaining = remaining % (24 * 60 * 60)
|
||||
}
|
||||
|
||||
// Time part (hours, minutes, seconds)
|
||||
if remaining > 0 {
|
||||
s += 'T'
|
||||
|
||||
// Hours
|
||||
hours := remaining / (60 * 60)
|
||||
if hours > 0 {
|
||||
s += '${hours}H'
|
||||
remaining = remaining % (60 * 60)
|
||||
}
|
||||
|
||||
// Minutes
|
||||
minutes := remaining / 60
|
||||
if minutes > 0 {
|
||||
s += '${minutes}M'
|
||||
remaining = remaining % 60
|
||||
}
|
||||
|
||||
// Seconds
|
||||
if remaining > 0 {
|
||||
s += '${remaining}S'
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Adds the duration to a timestamp
|
||||
pub fn (d Duration) add_to(timestamp i64) i64 {
|
||||
return timestamp + d.seconds
|
||||
}
|
||||
|
||||
// Subtracts the duration from a timestamp
|
||||
pub fn (d Duration) subtract_from(timestamp i64) i64 {
|
||||
return timestamp - d.seconds
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
module calbox
|
||||
|
||||
fn test_parse_duration() {
|
||||
// Test simple durations
|
||||
assert parse_duration('PT1H')!.seconds == 3600
|
||||
assert parse_duration('PT30M')!.seconds == 1800
|
||||
assert parse_duration('PT15S')!.seconds == 15
|
||||
|
||||
// Test combined durations
|
||||
assert parse_duration('PT1H30M')!.seconds == 5400
|
||||
assert parse_duration('PT1H30M15S')!.seconds == 5415
|
||||
|
||||
// Test days
|
||||
assert parse_duration('P1D')!.seconds == 86400
|
||||
assert parse_duration('P1DT12H')!.seconds == 129600
|
||||
|
||||
// Test weeks
|
||||
assert parse_duration('P2W')!.seconds == 1209600
|
||||
|
||||
// Test years (approximate)
|
||||
assert parse_duration('P1Y')!.seconds == 31536000
|
||||
|
||||
// Test zero duration
|
||||
assert parse_duration('PT0S')!.seconds == 0
|
||||
|
||||
// Test invalid formats
|
||||
if _ := parse_duration('invalid') {
|
||||
assert false, 'Should reject invalid format'
|
||||
}
|
||||
if _ := parse_duration('P') {
|
||||
assert false, 'Should reject empty duration'
|
||||
}
|
||||
if _ := parse_duration('PT1H30') {
|
||||
assert false, 'Should reject number without unit'
|
||||
}
|
||||
if _ := parse_duration('PT1HM') {
|
||||
assert false, 'Should reject missing number before unit'
|
||||
}
|
||||
if _ := parse_duration('P1H') {
|
||||
assert false, 'Should reject time unit in date part'
|
||||
}
|
||||
if _ := parse_duration('PT1D') {
|
||||
assert false, 'Should reject date unit in time part'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_duration_string() {
|
||||
// Test simple durations
|
||||
assert parse_duration('PT1H')!.str() == 'PT1H'
|
||||
assert parse_duration('PT30M')!.str() == 'PT30M'
|
||||
assert parse_duration('PT15S')!.str() == 'PT15S'
|
||||
|
||||
// Test combined durations
|
||||
assert parse_duration('PT1H30M')!.str() == 'PT1H30M'
|
||||
assert parse_duration('PT1H30M15S')!.str() == 'PT1H30M15S'
|
||||
|
||||
// Test days
|
||||
assert parse_duration('P1D')!.str() == 'P1D'
|
||||
assert parse_duration('P1DT12H')!.str() == 'P1DT12H'
|
||||
|
||||
// Test normalization
|
||||
d := parse_duration('PT90M')!
|
||||
assert d.str() == 'PT1H30M' // 90 minutes normalized to 1 hour 30 minutes
|
||||
|
||||
// Test zero duration
|
||||
assert parse_duration('PT0S')!.str() == 'P'
|
||||
}
|
||||
|
||||
fn test_duration_arithmetic() {
|
||||
// Base timestamp: 2024-02-16 10:00:00 UTC
|
||||
base_ts := i64(1708074000)
|
||||
|
||||
// Test addition
|
||||
one_hour := parse_duration('PT1H')!
|
||||
assert one_hour.add_to(base_ts) == base_ts + 3600
|
||||
|
||||
// Test subtraction
|
||||
assert one_hour.subtract_from(base_ts) == base_ts - 3600
|
||||
|
||||
// Test complex duration
|
||||
complex := parse_duration('P1DT2H30M')!
|
||||
expected := base_ts + (24 * 3600) + (2 * 3600) + (30 * 60)
|
||||
assert complex.add_to(base_ts) == expected
|
||||
}
|
||||
|
||||
fn test_duration_edge_cases() {
|
||||
// Test very large durations
|
||||
large := parse_duration('P100Y')!
|
||||
assert large.seconds == i64(100 * 365 * 24 * 60 * 60)
|
||||
|
||||
// Test combined date and time
|
||||
mixed := parse_duration('P1Y2DT3H4M5S')!
|
||||
assert mixed.str() == 'P1Y2DT3H4M5S'
|
||||
|
||||
// Test months (approximate)
|
||||
with_months := parse_duration('P2M')!
|
||||
assert with_months.seconds == i64(2 * 30 * 24 * 60 * 60)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
module calbox
|
||||
|
||||
// Represents an event
|
||||
@[heap]
|
||||
pub struct Event {
|
||||
CalendarComponent
|
||||
pub mut:
|
||||
start_time i64
|
||||
end_time ?i64 // Either end_time or duration must be set
|
||||
duration ?string // ISO 8601 duration format
|
||||
rrule ?RecurrenceRule
|
||||
rdate []i64 // Additional recurrence dates
|
||||
exdate []i64 // Dates to exclude
|
||||
transp EventTransp
|
||||
attendees []Attendee
|
||||
organizer ?Attendee
|
||||
}
|
||||
|
||||
// Event transparency (busy time)
|
||||
pub enum EventTransp {
|
||||
opaque // Blocks time (shows as busy)
|
||||
transparent // Does not block time (shows as free)
|
||||
}
|
||||
|
||||
// String representation of event transparency
|
||||
pub fn (t EventTransp) str() string {
|
||||
return match t {
|
||||
.opaque { 'OPAQUE' }
|
||||
.transparent { 'TRANSPARENT' }
|
||||
}
|
||||
}
|
||||
|
||||
// Parse event transparency from string
|
||||
pub fn parse_event_transp(s string) !EventTransp {
|
||||
return match s {
|
||||
'OPAQUE' { EventTransp.opaque }
|
||||
'TRANSPARENT' { EventTransp.transparent }
|
||||
else { error('Invalid event transparency: ${s}') }
|
||||
}
|
||||
}
|
||||
|
||||
// Checks if an event overlaps with a time range
|
||||
fn (event Event) overlaps(tr TimeRange) bool {
|
||||
// Get end time from either end_time or duration
|
||||
mut end_ts := event.end_time or {
|
||||
// TODO: Add duration parsing to get actual end time
|
||||
event.start_time + 3600 // Default 1 hour if no end/duration
|
||||
}
|
||||
|
||||
// Check basic overlap
|
||||
if is_in_range(event.start_time, tr) || is_in_range(end_ts, tr) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check recurrences if any
|
||||
if rule := event.rrule {
|
||||
// TODO: Implement recurrence expansion
|
||||
// For now just check if the rule's until date (if any) is after range start
|
||||
if until := rule.until {
|
||||
return until >= tr.start
|
||||
}
|
||||
return true // Infinite recurrence overlaps everything
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Gets the effective end time of an event
|
||||
pub fn (event Event) get_effective_end_time() !i64 {
|
||||
if end := event.end_time {
|
||||
return end
|
||||
}
|
||||
if dur_str := event.duration {
|
||||
duration := parse_duration(dur_str)!
|
||||
return duration.add_to(event.start_time)
|
||||
}
|
||||
// Default 1 hour duration
|
||||
return event.start_time + 3600
|
||||
}
|
||||
|
||||
// Gets all instances of an event that overlap with a time range
|
||||
pub fn (event Event) get_instances(tr TimeRange) ![]EventInstance {
|
||||
// For non-recurring events, just check if it overlaps
|
||||
if event.rrule == none && event.rdate.len == 0 {
|
||||
end_time := event.get_effective_end_time()!
|
||||
if event.start_time < tr.end && end_time > tr.start {
|
||||
return [
|
||||
EventInstance{
|
||||
original_event: event
|
||||
start_time: event.start_time
|
||||
end_time: end_time
|
||||
recurrence_id: event.start_time
|
||||
is_override: false
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// Expand recurring event
|
||||
if instances := expand_recurring_event(event, tr) {
|
||||
return instances
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
module calbox
|
||||
|
||||
// Represents a single instance of a recurring event
|
||||
pub struct EventInstance {
|
||||
pub:
|
||||
original_event Event // Reference to original event
|
||||
start_time i64 // Start time of this instance
|
||||
end_time i64 // End time of this instance
|
||||
recurrence_id i64 // RECURRENCE-ID for this instance
|
||||
is_override bool // Whether this is an overridden instance
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
module calbox
|
||||
|
||||
// Represents a journal entry
|
||||
@[heap]
|
||||
pub struct Journal {
|
||||
CalendarComponent
|
||||
pub mut:
|
||||
start_time i64 // Date of the journal entry
|
||||
attendees []Attendee
|
||||
organizer ?Attendee
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
module calbox
|
||||
|
||||
// Gets the next occurrence after a given timestamp based on a recurrence rule
|
||||
fn (rule RecurrenceRule) next_occurrence(base_time i64, after i64) ?i64 {
|
||||
if rule.until != none && after >= rule.until? {
|
||||
return none
|
||||
}
|
||||
|
||||
// Calculate interval in seconds based on frequency
|
||||
mut interval_seconds := i64(0)
|
||||
match rule.frequency {
|
||||
.secondly { interval_seconds = rule.interval }
|
||||
.minutely { interval_seconds = rule.interval * 60 }
|
||||
.hourly { interval_seconds = rule.interval * 3600 }
|
||||
.daily { interval_seconds = rule.interval * 86400 }
|
||||
.weekly { interval_seconds = rule.interval * 7 * 86400 }
|
||||
.monthly { interval_seconds = rule.interval * 30 * 86400 } // Approximate
|
||||
.yearly { interval_seconds = rule.interval * 365 * 86400 } // Approximate
|
||||
}
|
||||
|
||||
// Calculate number of intervals between base_time and after
|
||||
mut intervals := i64(0)
|
||||
if after > base_time {
|
||||
intervals = ((after - base_time) / interval_seconds) + 1
|
||||
}
|
||||
|
||||
// Calculate next occurrence
|
||||
mut next := base_time + (intervals * interval_seconds)
|
||||
|
||||
// TODO: Apply BYDAY, BYMONTHDAY etc. rules
|
||||
if rule.until != none && next > rule.until? {
|
||||
return none
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// Expands a recurring event into individual instances within a time range
|
||||
pub fn expand_recurring_event(event Event, tr TimeRange) ?[]EventInstance {
|
||||
mut instances := []EventInstance{}
|
||||
|
||||
// Get event duration
|
||||
mut duration := i64(0)
|
||||
if end := event.end_time {
|
||||
duration = end - event.start_time
|
||||
} else if dur_str := event.duration {
|
||||
duration = parse_duration(dur_str) or { return none }.seconds
|
||||
} else {
|
||||
duration = 3600 // Default 1 hour
|
||||
}
|
||||
|
||||
// Handle recurrence rule if any
|
||||
if rule := event.rrule {
|
||||
mut current := event.start_time
|
||||
mut total_instances := 0
|
||||
|
||||
// Generate instances until we hit count limit or range end
|
||||
for {
|
||||
// Add instance if in range and not excluded
|
||||
if current >= tr.start && current < tr.end && current !in event.exdate {
|
||||
instances << EventInstance{
|
||||
original_event: event
|
||||
start_time: current
|
||||
end_time: current + duration
|
||||
recurrence_id: current
|
||||
is_override: false
|
||||
}
|
||||
}
|
||||
|
||||
total_instances++
|
||||
|
||||
// Check count limit if specified
|
||||
if count := rule.count {
|
||||
if total_instances >= count {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate next occurrence
|
||||
mut interval_seconds := rule.interval * match rule.frequency {
|
||||
.secondly { 1 }
|
||||
.minutely { 60 }
|
||||
.hourly { 3600 }
|
||||
.daily { 86400 }
|
||||
.weekly { 7 * 86400 }
|
||||
.monthly { 30 * 86400 } // Approximate
|
||||
.yearly { 365 * 86400 } // Approximate
|
||||
}
|
||||
current += interval_seconds
|
||||
|
||||
// Break if we hit until limit
|
||||
if rule.until != none && current > rule.until? {
|
||||
break
|
||||
}
|
||||
|
||||
// Break if we're past the range and no count limit
|
||||
if current >= tr.end && rule.count == none {
|
||||
break
|
||||
}
|
||||
|
||||
// Break if we're past the range and have enough instances
|
||||
if current >= tr.end && total_instances >= (rule.count or { 0 }) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add any additional dates
|
||||
for rdate in event.rdate {
|
||||
if rdate >= tr.start && rdate < tr.end && rdate !in event.exdate {
|
||||
instances << EventInstance{
|
||||
original_event: event
|
||||
start_time: rdate
|
||||
end_time: rdate + duration
|
||||
recurrence_id: rdate
|
||||
is_override: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort instances by start time
|
||||
instances.sort(a.start_time < b.start_time)
|
||||
|
||||
return instances
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
module calbox
|
||||
|
||||
// Represents a recurrence rule
|
||||
@[heap]
|
||||
pub struct RecurrenceRule {
|
||||
pub mut:
|
||||
frequency RecurrenceFrequency
|
||||
interval int // How often the recurrence rule repeats
|
||||
count ?int // Number of occurrences
|
||||
until ?i64 // End date timestamp
|
||||
by_second []int
|
||||
by_minute []int
|
||||
by_hour []int
|
||||
by_day []string // MO, TU, WE, TH, FR, SA, SU with optional +/-prefix
|
||||
by_monthday []int
|
||||
by_yearday []int
|
||||
by_weekno []int
|
||||
by_month []int
|
||||
by_setpos []int
|
||||
week_start string // MO, TU, WE, TH, FR, SA, SU
|
||||
}
|
||||
|
||||
// Recurrence frequency types
|
||||
pub enum RecurrenceFrequency {
|
||||
secondly
|
||||
minutely
|
||||
hourly
|
||||
daily
|
||||
weekly
|
||||
monthly
|
||||
yearly
|
||||
}
|
||||
|
||||
// String representation of recurrence frequency
|
||||
pub fn (f RecurrenceFrequency) str() string {
|
||||
return match f {
|
||||
.secondly { 'SECONDLY' }
|
||||
.minutely { 'MINUTELY' }
|
||||
.hourly { 'HOURLY' }
|
||||
.daily { 'DAILY' }
|
||||
.weekly { 'WEEKLY' }
|
||||
.monthly { 'MONTHLY' }
|
||||
.yearly { 'YEARLY' }
|
||||
}
|
||||
}
|
||||
|
||||
// Parse recurrence frequency from string
|
||||
pub fn parse_recurrence_frequency(s string) !RecurrenceFrequency {
|
||||
return match s {
|
||||
'SECONDLY' { RecurrenceFrequency.secondly }
|
||||
'MINUTELY' { RecurrenceFrequency.minutely }
|
||||
'HOURLY' { RecurrenceFrequency.hourly }
|
||||
'DAILY' { RecurrenceFrequency.daily }
|
||||
'WEEKLY' { RecurrenceFrequency.weekly }
|
||||
'MONTHLY' { RecurrenceFrequency.monthly }
|
||||
'YEARLY' { RecurrenceFrequency.yearly }
|
||||
else { error('Invalid recurrence frequency: ${s}') }
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
module calbox
|
||||
|
||||
fn test_simple_recurrence() {
|
||||
// Create a daily recurring event
|
||||
event := Event{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'daily@example.com'
|
||||
}
|
||||
start_time: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
duration: 'PT1H'
|
||||
rrule: RecurrenceRule{
|
||||
frequency: .daily
|
||||
interval: 1
|
||||
count: 3
|
||||
}
|
||||
}
|
||||
|
||||
// Search for instances over 3 days
|
||||
tr := TimeRange{
|
||||
start: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
end: 1708333200 // 2024-02-19 10:00:00 UTC
|
||||
}
|
||||
|
||||
instances := event.get_instances(tr)!
|
||||
assert instances.len == 3
|
||||
|
||||
// Verify instance times
|
||||
assert instances[0].start_time == 1708074000 // Feb 16 10:00
|
||||
assert instances[1].start_time == 1708160400 // Feb 17 10:00
|
||||
assert instances[2].start_time == 1708246800 // Feb 18 10:00
|
||||
|
||||
// Verify duration
|
||||
for instance in instances {
|
||||
duration := instance.end_time - instance.start_time
|
||||
assert duration == 3600 // 1 hour
|
||||
}
|
||||
}
|
||||
|
||||
fn test_recurrence_with_until() {
|
||||
// Create event recurring daily until a specific time
|
||||
event := Event{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'until@example.com'
|
||||
}
|
||||
start_time: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
duration: 'PT1H'
|
||||
rrule: RecurrenceRule{
|
||||
frequency: .daily
|
||||
interval: 1
|
||||
until: 1708246800 // 2024-02-18 10:00:00 UTC
|
||||
}
|
||||
}
|
||||
|
||||
// Search beyond the until date
|
||||
tr := TimeRange{
|
||||
start: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
end: 1708333200 // 2024-02-19 10:00:00 UTC
|
||||
}
|
||||
|
||||
instances := event.get_instances(tr)!
|
||||
assert instances.len == 3 // Should include the until date
|
||||
}
|
||||
|
||||
fn test_recurrence_with_interval() {
|
||||
// Create event recurring every 2 days
|
||||
event := Event{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'interval@example.com'
|
||||
}
|
||||
start_time: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
duration: 'PT1H'
|
||||
rrule: RecurrenceRule{
|
||||
frequency: .daily
|
||||
interval: 2
|
||||
count: 3
|
||||
}
|
||||
}
|
||||
|
||||
// Search for a week
|
||||
tr := TimeRange{
|
||||
start: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
end: 1708592400 // 2024-02-22 10:00:00 UTC
|
||||
}
|
||||
|
||||
instances := event.get_instances(tr)!
|
||||
assert instances.len == 3
|
||||
|
||||
// Verify instance times (every 2 days)
|
||||
assert instances[0].start_time == 1708074000 // Feb 16 10:00
|
||||
assert instances[1].start_time == 1708246800 // Feb 18 10:00
|
||||
assert instances[2].start_time == 1708419600 // Feb 20 10:00
|
||||
}
|
||||
|
||||
fn test_recurrence_with_exclusions() {
|
||||
// Create daily event with exclusions
|
||||
event := Event{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'exclude@example.com'
|
||||
}
|
||||
start_time: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
duration: 'PT1H'
|
||||
rrule: RecurrenceRule{
|
||||
frequency: .daily
|
||||
interval: 1
|
||||
count: 5
|
||||
}
|
||||
exdate: [i64(1708246800)] // Exclude Feb 18 10:00
|
||||
}
|
||||
|
||||
// Search for a week
|
||||
tr := TimeRange{
|
||||
start: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
end: 1708592400 // 2024-02-22 10:00:00 UTC
|
||||
}
|
||||
|
||||
instances := event.get_instances(tr)!
|
||||
assert instances.len == 4 // 5 occurrences - 1 exclusion
|
||||
|
||||
// Verify excluded date is not present
|
||||
for instance in instances {
|
||||
assert instance.start_time != 1708246800
|
||||
}
|
||||
}
|
||||
|
||||
fn test_recurrence_with_additional_dates() {
|
||||
// Create event with additional dates
|
||||
event := Event{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'rdate@example.com'
|
||||
}
|
||||
start_time: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
duration: 'PT1H'
|
||||
rrule: RecurrenceRule{
|
||||
frequency: .daily
|
||||
interval: 1
|
||||
count: 2
|
||||
}
|
||||
rdate: [
|
||||
i64(1708333200), // Feb 19 10:00
|
||||
i64(1708419600), // Feb 20 10:00
|
||||
]
|
||||
}
|
||||
|
||||
// Search for a week
|
||||
tr := TimeRange{
|
||||
start: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
end: 1708592400 // 2024-02-22 10:00:00 UTC
|
||||
}
|
||||
|
||||
instances := event.get_instances(tr)!
|
||||
assert instances.len == 4 // 2 regular + 2 additional
|
||||
|
||||
// Verify additional dates are included
|
||||
mut found_additional := false
|
||||
for instance in instances {
|
||||
if instance.start_time == 1708333200 || instance.start_time == 1708419600 {
|
||||
found_additional = true
|
||||
}
|
||||
}
|
||||
assert found_additional
|
||||
}
|
||||
|
||||
fn test_non_recurring_event() {
|
||||
// Create single event
|
||||
event := Event{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'single@example.com'
|
||||
}
|
||||
start_time: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
end_time: 1708077600 // 2024-02-16 11:00:00 UTC
|
||||
}
|
||||
|
||||
// Search including event time
|
||||
tr := TimeRange{
|
||||
start: 1708070400 // 2024-02-16 09:00:00 UTC
|
||||
end: 1708084800 // 2024-02-16 12:00:00 UTC
|
||||
}
|
||||
|
||||
instances := event.get_instances(tr)!
|
||||
assert instances.len == 1
|
||||
|
||||
// Search outside event time
|
||||
tr_outside := TimeRange{
|
||||
start: 1708333200 // 2024-02-19 10:00:00 UTC
|
||||
end: 1708336800 // 2024-02-19 11:00:00 UTC
|
||||
}
|
||||
|
||||
outside_instances := event.get_instances(tr_outside)!
|
||||
assert outside_instances.len == 0
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
module calbox
|
||||
|
||||
@[params]
|
||||
pub struct TimeRange {
|
||||
pub mut:
|
||||
start i64 // UTC timestamp (epoch)
|
||||
end i64 // UTC timestamp
|
||||
}
|
||||
|
||||
// Checks if a timestamp falls within a time range
|
||||
fn is_in_range(ts i64, tr TimeRange) bool {
|
||||
return ts >= tr.start && ts < tr.end
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
module calbox
|
||||
|
||||
// Represents a todo/task
|
||||
@[heap]
|
||||
pub struct Todo {
|
||||
CalendarComponent
|
||||
pub mut:
|
||||
start_time ?i64 // Optional start time
|
||||
due_time ?i64 // When the todo is due
|
||||
duration ?string // Estimated duration
|
||||
completed ?i64 // When the todo was completed
|
||||
percent ?int // Percent complete (0-100)
|
||||
rrule ?RecurrenceRule
|
||||
attendees []Attendee
|
||||
organizer ?Attendee
|
||||
}
|
||||
|
||||
// Checks if a todo overlaps with a time range
|
||||
fn (todo Todo) overlaps(tr TimeRange) bool {
|
||||
if start := todo.start_time {
|
||||
if is_in_range(start, tr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if due := todo.due_time {
|
||||
if is_in_range(due, tr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if completed := todo.completed {
|
||||
if is_in_range(completed, tr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
module caldav
|
||||
|
||||
// CalDAV privileges
|
||||
pub const read_free_busy = 'read-free-busy' // Allows reading free/busy information
|
||||
|
||||
pub const read = 'read' // Allows reading calendar data
|
||||
|
||||
pub const write = 'write' // Allows writing calendar data
|
||||
|
||||
pub const write_content = 'write-content' // Allows modifying calendar object resources
|
||||
|
||||
pub const write_props = 'write-props' // Allows modifying collection properties
|
||||
|
||||
pub const bind = 'bind' // Allows creating new calendar object resources
|
||||
|
||||
pub const unbind = 'unbind' // Allows deleting calendar object resources
|
||||
|
||||
pub const admin = 'admin' // Allows administrative operations
|
||||
|
||||
// Principal represents a user or group
|
||||
pub struct Principal {
|
||||
pub mut:
|
||||
id string
|
||||
name string
|
||||
email string
|
||||
calendar_home_set string
|
||||
}
|
||||
|
||||
// ACLEntry represents an access control entry
|
||||
pub struct ACLEntry {
|
||||
pub mut:
|
||||
principal Principal
|
||||
privileges []string
|
||||
inherited bool
|
||||
protected bool
|
||||
}
|
||||
|
||||
// ACL represents an access control list
|
||||
pub struct ACL {
|
||||
pub mut:
|
||||
entries []ACLEntry
|
||||
}
|
||||
|
||||
// Creates a new ACL
|
||||
pub fn new_acl() ACL {
|
||||
return ACL{
|
||||
entries: []ACLEntry{}
|
||||
}
|
||||
}
|
||||
|
||||
// Adds an ACL entry
|
||||
pub fn (mut acl ACL) add_entry(principal Principal, privileges []string, inherited bool, protected bool) {
|
||||
acl.entries << ACLEntry{
|
||||
principal: principal
|
||||
privileges: privileges
|
||||
inherited: inherited
|
||||
protected: protected
|
||||
}
|
||||
}
|
||||
|
||||
// Checks if a principal has a privilege
|
||||
pub fn (acl ACL) has_privilege(principal Principal, privilege string) bool {
|
||||
for entry in acl.entries {
|
||||
if entry.principal.id == principal.id {
|
||||
return privilege in entry.privileges
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Gets all privileges for a principal
|
||||
pub fn (acl ACL) get_privileges(principal Principal) []string {
|
||||
for entry in acl.entries {
|
||||
if entry.principal.id == principal.id {
|
||||
return entry.privileges
|
||||
}
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// Removes an ACL entry
|
||||
pub fn (mut acl ACL) remove_entry(principal Principal) {
|
||||
for i, entry in acl.entries {
|
||||
if entry.principal.id == principal.id {
|
||||
acl.entries.delete(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Checks if a principal has read access
|
||||
pub fn (acl ACL) can_read(principal Principal) bool {
|
||||
return acl.has_privilege(principal, read) || acl.has_privilege(principal, admin)
|
||||
}
|
||||
|
||||
// Checks if a principal has write access
|
||||
pub fn (acl ACL) can_write(principal Principal) bool {
|
||||
return acl.has_privilege(principal, write) || acl.has_privilege(principal, admin)
|
||||
}
|
||||
|
||||
// Checks if a principal has free/busy read access
|
||||
pub fn (acl ACL) can_read_freebusy(principal Principal) bool {
|
||||
return acl.has_privilege(principal, read_free_busy) || acl.can_read(principal)
|
||||
}
|
||||
|
||||
// Checks if a principal can bind (create) resources
|
||||
pub fn (acl ACL) can_bind(principal Principal) bool {
|
||||
return acl.has_privilege(principal, bind) || acl.has_privilege(principal, write)
|
||||
}
|
||||
|
||||
// Checks if a principal can unbind (delete) resources
|
||||
pub fn (acl ACL) can_unbind(principal Principal) bool {
|
||||
return acl.has_privilege(principal, unbind) || acl.has_privilege(principal, write)
|
||||
}
|
||||
|
||||
// Checks if a principal has admin access
|
||||
pub fn (acl ACL) is_admin(principal Principal) bool {
|
||||
return acl.has_privilege(principal, admin)
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
module caldav
|
||||
|
||||
import calendar.calbox
|
||||
|
||||
// Represents a CalDAV collection
|
||||
pub struct Collection {
|
||||
pub mut:
|
||||
path string
|
||||
cal &calbox.CalBox
|
||||
props map[string]string
|
||||
acl ACL
|
||||
parent ?&Collection
|
||||
children map[string]&Collection
|
||||
is_calendar bool
|
||||
}
|
||||
|
||||
// Creates a new collection
|
||||
pub fn new_collection(path string, is_calendar bool) &Collection {
|
||||
mut col := &Collection{
|
||||
path: path
|
||||
props: map[string]string{}
|
||||
acl: new_acl()
|
||||
children: map[string]&Collection{}
|
||||
is_calendar: is_calendar
|
||||
}
|
||||
|
||||
if is_calendar {
|
||||
col.cal = calbox.new(path.all_after_last('/'))
|
||||
}
|
||||
|
||||
return col
|
||||
}
|
||||
|
||||
// Gets a child collection by name
|
||||
pub fn (c Collection) get_child(name string) ?&Collection {
|
||||
return c.children[name]
|
||||
}
|
||||
|
||||
// Adds a child collection
|
||||
pub fn (mut c Collection) add_child(name string, child &Collection) {
|
||||
c.children[name] = child
|
||||
child.parent = c
|
||||
}
|
||||
|
||||
// Removes a child collection
|
||||
pub fn (mut c Collection) remove_child(name string) {
|
||||
c.children.delete(name)
|
||||
}
|
||||
|
||||
// Gets all child collections
|
||||
pub fn (c Collection) list_children() []&Collection {
|
||||
mut children := []&Collection{}
|
||||
for _, child in c.children {
|
||||
children << child
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
// Gets all calendar collections
|
||||
pub fn (c Collection) list_calendars() []&Collection {
|
||||
mut calendars := []&Collection{}
|
||||
for _, child in c.children {
|
||||
if child.is_calendar {
|
||||
calendars << child
|
||||
}
|
||||
calendars << child.list_calendars()
|
||||
}
|
||||
return calendars
|
||||
}
|
||||
|
||||
// Gets all calendar collections for a principal
|
||||
pub fn (c Collection) list_principal_calendars(principal Principal) []&Collection {
|
||||
mut calendars := []&Collection{}
|
||||
for cal in c.list_calendars() {
|
||||
if cal.acl.can_read(principal) {
|
||||
calendars << cal
|
||||
}
|
||||
}
|
||||
return calendars
|
||||
}
|
||||
|
||||
// Gets a collection by path
|
||||
pub fn (c Collection) find_by_path(path string) ?&Collection {
|
||||
if c.path == path {
|
||||
return c
|
||||
}
|
||||
|
||||
parts := path.trim_left('/').split('/')
|
||||
mut current := &c
|
||||
|
||||
for part in parts {
|
||||
if child := current.get_child(part) {
|
||||
current = child
|
||||
} else {
|
||||
return none
|
||||
}
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
// Gets a calendar object by href
|
||||
pub fn (c Collection) get_object_by_href(href string) ?calbox.CalendarObject {
|
||||
if !c.is_calendar {
|
||||
return none
|
||||
}
|
||||
|
||||
uid := href.all_after_last('/')
|
||||
return c.cal.get_by_uid(uid)
|
||||
}
|
||||
|
||||
// Gets all calendar objects
|
||||
pub fn (c Collection) list_objects() []calbox.CalendarObject {
|
||||
if !c.is_calendar {
|
||||
return []calbox.CalendarObject{}
|
||||
}
|
||||
|
||||
return c.cal.list() or { []calbox.CalendarObject{} }
|
||||
}
|
||||
|
||||
// Gets all calendar objects matching a filter
|
||||
pub fn (c Collection) find_objects(filter CalendarQueryFilter) []calbox.CalendarObject {
|
||||
if !c.is_calendar {
|
||||
return []calbox.CalendarObject{}
|
||||
}
|
||||
|
||||
return c.cal.find_by_filter(filter) or { []calbox.CalendarObject{} }
|
||||
}
|
||||
|
||||
// Gets free/busy information
|
||||
pub fn (c Collection) get_freebusy(tr calbox.TimeRange) []calbox.TimeRange {
|
||||
if !c.is_calendar {
|
||||
return []calbox.TimeRange{}
|
||||
}
|
||||
|
||||
return c.cal.get_freebusy(tr) or { []calbox.TimeRange{} }
|
||||
}
|
||||
|
||||
// Adds/updates a calendar object
|
||||
pub fn (mut c Collection) put_object(obj calbox.CalendarObject) ! {
|
||||
if !c.is_calendar {
|
||||
return error('Not a calendar collection')
|
||||
}
|
||||
|
||||
c.cal.put(obj)!
|
||||
}
|
||||
|
||||
// Deletes a calendar object
|
||||
pub fn (mut c Collection) delete_object(uid string) ! {
|
||||
if !c.is_calendar {
|
||||
return error('Not a calendar collection')
|
||||
}
|
||||
|
||||
c.cal.delete(uid)!
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
module caldav
|
||||
|
||||
// Error codes
|
||||
pub const err_not_found = 404
|
||||
pub const err_forbidden = 403
|
||||
pub const err_conflict = 409
|
||||
pub const err_precondition_failed = 412
|
||||
pub const err_insufficient_storage = 507
|
||||
|
||||
// Error types
|
||||
pub const err_calendar_not_found = 'Calendar collection not found'
|
||||
pub const err_resource_not_found = 'Calendar object resource not found'
|
||||
pub const err_calendar_exists = 'Calendar collection already exists'
|
||||
pub const err_resource_exists = 'Calendar object resource already exists'
|
||||
pub const err_invalid_calendar = 'Invalid calendar collection'
|
||||
pub const err_invalid_resource = 'Invalid calendar object resource'
|
||||
pub const err_uid_conflict = 'UID already in use'
|
||||
pub const err_no_privilege = 'Insufficient privileges'
|
||||
pub const err_storage_full = 'Insufficient storage space'
|
||||
|
||||
// Precondition errors
|
||||
pub const err_supported_calendar_data = 'Unsupported calendar data format'
|
||||
pub const err_valid_calendar_data = 'Invalid calendar data'
|
||||
pub const err_valid_calendar_object = 'Invalid calendar object'
|
||||
pub const err_supported_component = 'Unsupported calendar component'
|
||||
pub const err_calendar_collection_location = 'Invalid calendar collection location'
|
||||
pub const err_resource_must_be_null = 'Resource already exists'
|
||||
pub const err_valid_calendar_timezone = 'Invalid calendar timezone'
|
||||
|
||||
// CalDAV error with code and message
|
||||
pub struct CalDAVError {
|
||||
pub:
|
||||
code int
|
||||
message string
|
||||
href string // Optional resource href for errors like UID conflicts
|
||||
}
|
||||
|
||||
// Creates a new CalDAV error
|
||||
pub fn new_error(code int, message string) CalDAVError {
|
||||
return CalDAVError{
|
||||
code: code
|
||||
message: message
|
||||
}
|
||||
}
|
||||
|
||||
// Creates a new CalDAV error with href
|
||||
pub fn new_error_with_href(code int, message string, href string) CalDAVError {
|
||||
return CalDAVError{
|
||||
code: code
|
||||
message: message
|
||||
href: href
|
||||
}
|
||||
}
|
||||
|
||||
// Common error constructors
|
||||
|
||||
// Resource not found error
|
||||
pub fn err_not_found_error(message string) CalDAVError {
|
||||
return new_error(err_not_found, message)
|
||||
}
|
||||
|
||||
// Permission denied error
|
||||
pub fn err_forbidden_error(message string) CalDAVError {
|
||||
return new_error(err_forbidden, message)
|
||||
}
|
||||
|
||||
// Resource conflict error
|
||||
pub fn err_conflict_error(message string) CalDAVError {
|
||||
return new_error(err_conflict, message)
|
||||
}
|
||||
|
||||
// Precondition failed error
|
||||
pub fn err_precondition_error(message string) CalDAVError {
|
||||
return new_error(err_precondition_failed, message)
|
||||
}
|
||||
|
||||
// Storage full error
|
||||
pub fn err_storage_error(message string) CalDAVError {
|
||||
return new_error(err_insufficient_storage, message)
|
||||
}
|
||||
|
||||
// UID conflict error with href
|
||||
pub fn err_uid_conflict_error(href string) CalDAVError {
|
||||
return new_error_with_href(err_conflict, err_uid_conflict, href)
|
||||
}
|
||||
|
||||
// Generates XML error response
|
||||
pub fn (err CalDAVError) to_xml() string {
|
||||
mut xml := '<?xml version="1.0" encoding="utf-8" ?>\n'
|
||||
xml += '<D:error xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">\n'
|
||||
|
||||
match err.code {
|
||||
err_forbidden {
|
||||
xml += ' <D:need-privileges>\n'
|
||||
xml += ' <D:resource>\n'
|
||||
xml += ' <D:privilege>${err.message}</D:privilege>\n'
|
||||
xml += ' </D:resource>\n'
|
||||
xml += ' </D:need-privileges>\n'
|
||||
}
|
||||
err_conflict {
|
||||
if err.message == err_uid_conflict && err.href != '' {
|
||||
xml += ' <C:no-uid-conflict><D:href>${err.href}</D:href></C:no-uid-conflict>\n'
|
||||
} else {
|
||||
xml += ' <D:resource-must-be-null/>\n'
|
||||
}
|
||||
}
|
||||
err_precondition_failed {
|
||||
xml += ' <C:supported-calendar-data/>\n'
|
||||
xml += ' <C:valid-calendar-data/>\n'
|
||||
xml += ' <C:valid-calendar-object/>\n'
|
||||
xml += ' <C:supported-calendar-component/>\n'
|
||||
xml += ' <C:calendar-collection-location-ok/>\n'
|
||||
xml += ' <C:max-resource-size/>\n'
|
||||
xml += ' <C:min-date-time/>\n'
|
||||
xml += ' <C:max-date-time/>\n'
|
||||
xml += ' <C:max-instances/>\n'
|
||||
xml += ' <C:max-attendees-per-instance/>\n'
|
||||
}
|
||||
else {
|
||||
xml += ' <D:error><![CDATA[${err.message}]]></D:error>\n'
|
||||
}
|
||||
}
|
||||
|
||||
xml += '</D:error>'
|
||||
return xml
|
||||
}
|
||||
@@ -1,584 +0,0 @@
|
||||
module caldav
|
||||
|
||||
import calendar.calbox
|
||||
import time
|
||||
|
||||
// Formats a timestamp as an iCalendar date-time string
|
||||
fn format_datetime(ts i64) string {
|
||||
return calbox.format_datetime_utc(ts)
|
||||
}
|
||||
|
||||
// Formats a date-time property with optional parameters
|
||||
fn format_datetime_property(name string, params map[string]string, ts i64) string {
|
||||
return format_property(name, params, calbox.format_datetime_utc(ts))
|
||||
}
|
||||
|
||||
// Formats a date property with optional parameters
|
||||
fn format_date_property(name string, params map[string]string, ts i64) string {
|
||||
return format_property(name, params, calbox.format_date(ts))
|
||||
}
|
||||
|
||||
// Formats a date or date-time property based on has_time flag
|
||||
fn format_date_or_datetime_property(name string, params map[string]string, ts i64, has_time bool) string {
|
||||
return format_property(name, params, calbox.format_date_or_datetime(ts, has_time))
|
||||
}
|
||||
|
||||
// Splits a line into property name, parameters, and value
|
||||
fn parse_property(line string) !(string, map[string]string, string) {
|
||||
// Find the colon that separates name+params from value
|
||||
value_start := line.index(':') or { return error('Invalid property format: missing colon') }
|
||||
|
||||
// Split into name+params and value
|
||||
name_params := line[..value_start].trim_space()
|
||||
value := line[value_start + 1..].trim_space()
|
||||
|
||||
// Split name and parameters
|
||||
parts := name_params.split(';')
|
||||
name := parts[0]
|
||||
|
||||
// Parse parameters
|
||||
mut params := map[string]string{}
|
||||
for i := 1; i < parts.len; i++ {
|
||||
param_parts := parts[i].split('=')
|
||||
if param_parts.len == 2 {
|
||||
params[param_parts[0]] = param_parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
return name, params, value
|
||||
}
|
||||
|
||||
// Parses an iCalendar content line
|
||||
fn parse_content_line(line string) !string {
|
||||
// Handle line unfolding (lines starting with space/tab are continuations)
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
return line[1..]
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// Parses an attendee property value
|
||||
fn parse_attendee(value string, params map[string]string) !calbox.Attendee {
|
||||
mut attendee := calbox.Attendee{
|
||||
email: value.replace('mailto:', '')
|
||||
}
|
||||
|
||||
if cn := params['CN'] {
|
||||
attendee.name = cn
|
||||
}
|
||||
if role := params['ROLE'] {
|
||||
attendee.role = calbox.parse_attendee_role(role)!
|
||||
}
|
||||
if partstat := params['PARTSTAT'] {
|
||||
attendee.partstat = calbox.parse_attendee_partstat(partstat)!
|
||||
}
|
||||
if rsvp := params['RSVP'] {
|
||||
attendee.rsvp = rsvp == 'TRUE'
|
||||
}
|
||||
|
||||
return attendee
|
||||
}
|
||||
|
||||
// Parses a recurrence rule
|
||||
fn parse_rrule(value string) !calbox.RecurrenceRule {
|
||||
mut rule := calbox.RecurrenceRule{
|
||||
interval: 1 // Default interval
|
||||
}
|
||||
|
||||
parts := value.split(';')
|
||||
for part in parts {
|
||||
name_value := part.split('=')
|
||||
if name_value.len != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
name := name_value[0]
|
||||
val := name_value[1]
|
||||
|
||||
match name {
|
||||
'FREQ' { rule.frequency = calbox.parse_recurrence_frequency(val)! }
|
||||
'INTERVAL' { rule.interval = val.int() }
|
||||
'COUNT' { rule.count = val.int() }
|
||||
'UNTIL' { rule.until = calbox.parse_datetime(val)! }
|
||||
'BYDAY' { rule.by_day = val.split(',') }
|
||||
'BYMONTH' { rule.by_month = val.split(',').map(it.int()) }
|
||||
'BYMONTHDAY' { rule.by_monthday = val.split(',').map(it.int()) }
|
||||
'WKST' { rule.week_start = val }
|
||||
else {}
|
||||
}
|
||||
}
|
||||
|
||||
return rule
|
||||
}
|
||||
|
||||
// Parses an alarm component
|
||||
fn parse_alarm(lines []string) !calbox.Alarm {
|
||||
mut alarm := calbox.Alarm{}
|
||||
for i := 0; i < lines.len; i++ {
|
||||
line := lines[i]
|
||||
if line == 'END:VALARM' {
|
||||
break
|
||||
}
|
||||
|
||||
name, params, value := parse_property(line)!
|
||||
match name {
|
||||
'ACTION' { alarm.action = calbox.parse_alarm_action(value)! }
|
||||
'TRIGGER' { alarm.trigger = value }
|
||||
'DESCRIPTION' { alarm.description = value }
|
||||
'SUMMARY' { alarm.summary = value }
|
||||
'ATTENDEE' { alarm.attendees << parse_attendee(value, params)! }
|
||||
'ATTACH' { alarm.attach << value }
|
||||
else {}
|
||||
}
|
||||
}
|
||||
|
||||
return alarm
|
||||
}
|
||||
|
||||
// Parses common component properties
|
||||
fn parse_component(lines []string, mut comp calbox.CalendarComponent) ! {
|
||||
for line in lines {
|
||||
if line.starts_with('BEGIN:') || line.starts_with('END:') {
|
||||
continue
|
||||
}
|
||||
|
||||
name, params, value := parse_property(line)!
|
||||
match name {
|
||||
'UID' {
|
||||
comp.uid = value
|
||||
}
|
||||
'DTSTAMP' {
|
||||
comp.created = calbox.parse_datetime(value)!
|
||||
}
|
||||
'LAST-MODIFIED' {
|
||||
comp.modified = calbox.parse_datetime(value)!
|
||||
}
|
||||
'SUMMARY' {
|
||||
comp.summary = value
|
||||
}
|
||||
'DESCRIPTION' {
|
||||
comp.description = value
|
||||
}
|
||||
'CATEGORIES' {
|
||||
comp.categories = value.split(',')
|
||||
}
|
||||
'STATUS' {
|
||||
comp.status = calbox.parse_component_status(value)!
|
||||
}
|
||||
'CLASS' {
|
||||
comp.class = calbox.parse_component_class(value)!
|
||||
}
|
||||
'URL' {
|
||||
comp.url = value
|
||||
}
|
||||
'LOCATION' {
|
||||
comp.location = value
|
||||
}
|
||||
'GEO' {
|
||||
parts := value.split(';')
|
||||
if parts.len == 2 {
|
||||
comp.geo = calbox.GeoLocation{
|
||||
latitude: parts[0].f64()
|
||||
longitude: parts[1].f64()
|
||||
}
|
||||
}
|
||||
}
|
||||
else {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parses an iCalendar string into a calendar object
|
||||
pub fn from_ical(ical string) !calbox.CalendarObject {
|
||||
lines := ical.split_into_lines().map(parse_content_line)!
|
||||
|
||||
mut obj := calbox.CalendarObject{}
|
||||
mut in_component := false
|
||||
mut component_lines := []string{}
|
||||
|
||||
for line in lines {
|
||||
if line.starts_with('BEGIN:V') {
|
||||
if line == 'BEGIN:VCALENDAR' {
|
||||
continue
|
||||
}
|
||||
in_component = true
|
||||
obj.comp_type = line.replace('BEGIN:', '')
|
||||
continue
|
||||
}
|
||||
|
||||
if line.starts_with('END:V') {
|
||||
if line == 'END:VCALENDAR' {
|
||||
continue
|
||||
}
|
||||
in_component = false
|
||||
|
||||
// Parse the collected component
|
||||
match obj.comp_type {
|
||||
'VEVENT' {
|
||||
mut event := calbox.Event{}
|
||||
parse_component(component_lines, mut event.CalendarComponent)!
|
||||
for comp_line in component_lines {
|
||||
name, params, value := parse_property(comp_line)!
|
||||
match name {
|
||||
'DTSTART' { event.start_time = calbox.parse_datetime(value)! }
|
||||
'DTEND' { event.end_time = calbox.parse_datetime(value)! }
|
||||
'DURATION' { event.duration = value }
|
||||
'RRULE' { event.rrule = parse_rrule(value)! }
|
||||
'RDATE' { event.rdate << calbox.parse_datetime(value)! }
|
||||
'EXDATE' { event.exdate << calbox.parse_datetime(value)! }
|
||||
'TRANSP' { event.transp = calbox.parse_event_transp(value)! }
|
||||
'ATTENDEE' { event.attendees << parse_attendee(value, params)! }
|
||||
'ORGANIZER' { event.organizer = parse_attendee(value, params)! }
|
||||
else {}
|
||||
}
|
||||
}
|
||||
obj.event = event
|
||||
}
|
||||
'VTODO' {
|
||||
mut todo := calbox.Todo{}
|
||||
parse_component(component_lines, mut todo.CalendarComponent)!
|
||||
for comp_line in component_lines {
|
||||
name, params, value := parse_property(comp_line)!
|
||||
match name {
|
||||
'DTSTART' { todo.start_time = calbox.parse_datetime(value)! }
|
||||
'DUE' { todo.due_time = calbox.parse_datetime(value)! }
|
||||
'DURATION' { todo.duration = value }
|
||||
'COMPLETED' { todo.completed = calbox.parse_datetime(value)! }
|
||||
'PERCENT-COMPLETE' { todo.percent = value.int() }
|
||||
'RRULE' { todo.rrule = parse_rrule(value)! }
|
||||
'ATTENDEE' { todo.attendees << parse_attendee(value, params)! }
|
||||
'ORGANIZER' { todo.organizer = parse_attendee(value, params)! }
|
||||
else {}
|
||||
}
|
||||
}
|
||||
obj.todo = todo
|
||||
}
|
||||
'VJOURNAL' {
|
||||
mut journal := calbox.Journal{}
|
||||
parse_component(component_lines, mut journal.CalendarComponent)!
|
||||
for comp_line in component_lines {
|
||||
name, params, value := parse_property(comp_line)!
|
||||
match name {
|
||||
'DTSTART' { journal.start_time = calbox.parse_datetime(value)! }
|
||||
'ATTENDEE' { journal.attendees << parse_attendee(value, params)! }
|
||||
'ORGANIZER' { journal.organizer = parse_attendee(value, params)! }
|
||||
else {}
|
||||
}
|
||||
}
|
||||
obj.journal = journal
|
||||
}
|
||||
else {}
|
||||
}
|
||||
|
||||
component_lines.clear()
|
||||
continue
|
||||
}
|
||||
|
||||
if in_component {
|
||||
component_lines << line
|
||||
}
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
// Folds a line according to iCalendar spec (max 75 chars)
|
||||
fn fold_line(line string) string {
|
||||
if line.len <= 75 {
|
||||
return line
|
||||
}
|
||||
|
||||
mut result := []string{}
|
||||
mut current := line[0..75]
|
||||
mut remaining := line[75..]
|
||||
result << current
|
||||
|
||||
for remaining.len > 0 {
|
||||
if remaining.len <= 74 {
|
||||
result << ' ${remaining}'
|
||||
break
|
||||
}
|
||||
current = remaining[0..74]
|
||||
remaining = remaining[74..]
|
||||
result << ' ${current}'
|
||||
}
|
||||
|
||||
return result.join('\r\n')
|
||||
}
|
||||
|
||||
// Formats a property with optional parameters
|
||||
fn format_property(name string, params map[string]string, value string) string {
|
||||
mut param_str := ''
|
||||
if params.len > 0 {
|
||||
param_parts := params.keys().map(fn (k string, v map[string]string) string {
|
||||
return '${k}=${v[k]}'
|
||||
})
|
||||
param_str = ';${param_parts.join(';')}'
|
||||
}
|
||||
|
||||
line := '${name}${param_str}:${value}'
|
||||
return fold_line(line)
|
||||
}
|
||||
|
||||
// Formats attendee properties
|
||||
fn format_attendee(a calbox.Attendee) string {
|
||||
mut props := []string{}
|
||||
|
||||
props << 'ROLE=${a.role.str()}'
|
||||
props << 'PARTSTAT=${a.partstat.str()}'
|
||||
|
||||
if a.rsvp {
|
||||
props << 'RSVP=TRUE'
|
||||
}
|
||||
if a.name != '' {
|
||||
props << 'CN=${a.name}'
|
||||
}
|
||||
|
||||
param_str := if props.len > 0 { ';${props.join(';')}' } else { '' }
|
||||
return 'ATTENDEE${param_str}:mailto:${a.email}'
|
||||
}
|
||||
|
||||
// Formats a recurrence rule
|
||||
fn format_rrule(r calbox.RecurrenceRule) string {
|
||||
mut parts := []string{}
|
||||
parts << 'FREQ=${r.frequency.str()}'
|
||||
|
||||
if r.interval > 1 {
|
||||
parts << 'INTERVAL=${r.interval}'
|
||||
}
|
||||
|
||||
if count := r.count {
|
||||
parts << 'COUNT=${count}'
|
||||
}
|
||||
|
||||
if until := r.until {
|
||||
parts << 'UNTIL=${format_datetime(until)}'
|
||||
}
|
||||
|
||||
if r.by_day.len > 0 {
|
||||
parts << 'BYDAY=${r.by_day.join(',')}'
|
||||
}
|
||||
|
||||
if r.by_month.len > 0 {
|
||||
parts << 'BYMONTH=${r.by_month.map(it.str()).join(',')}'
|
||||
}
|
||||
|
||||
if r.by_monthday.len > 0 {
|
||||
parts << 'BYMONTHDAY=${r.by_monthday.map(it.str()).join(',')}'
|
||||
}
|
||||
|
||||
if r.week_start != '' {
|
||||
parts << 'WKST=${r.week_start}'
|
||||
}
|
||||
|
||||
return 'RRULE:${parts.join(';')}'
|
||||
}
|
||||
|
||||
// Formats alarm properties
|
||||
fn format_alarm(a calbox.Alarm) string {
|
||||
mut lines := []string{}
|
||||
lines << 'BEGIN:VALARM'
|
||||
lines << 'ACTION:${a.action.str()}'
|
||||
lines << 'TRIGGER:${a.trigger}'
|
||||
|
||||
if a.description != '' {
|
||||
lines << 'DESCRIPTION:${a.description}'
|
||||
}
|
||||
|
||||
if a.summary != '' {
|
||||
lines << 'SUMMARY:${a.summary}'
|
||||
}
|
||||
|
||||
for attendee in a.attendees {
|
||||
lines << format_attendee(attendee)
|
||||
}
|
||||
|
||||
for attach in a.attach {
|
||||
lines << 'ATTACH:${attach}'
|
||||
}
|
||||
|
||||
lines << 'END:VALARM'
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
// Formats common component properties
|
||||
fn format_component(c calbox.CalendarComponent) []string {
|
||||
mut lines := []string{}
|
||||
|
||||
lines << 'UID:${c.uid}'
|
||||
lines << 'DTSTAMP:${format_datetime(c.created)}'
|
||||
|
||||
if c.summary != '' {
|
||||
lines << 'SUMMARY:${c.summary}'
|
||||
}
|
||||
|
||||
if c.description != '' {
|
||||
lines << 'DESCRIPTION:${c.description}'
|
||||
}
|
||||
|
||||
if c.categories.len > 0 {
|
||||
lines << 'CATEGORIES:${c.categories.join(',')}'
|
||||
}
|
||||
|
||||
lines << 'STATUS:${c.status.str()}'
|
||||
lines << 'CLASS:${c.class.str()}'
|
||||
|
||||
if c.url != '' {
|
||||
lines << 'URL:${c.url}'
|
||||
}
|
||||
|
||||
if c.location != '' {
|
||||
lines << 'LOCATION:${c.location}'
|
||||
}
|
||||
|
||||
if geo := c.geo {
|
||||
lines << 'GEO:${geo.latitude};${geo.longitude}'
|
||||
}
|
||||
|
||||
for alarm in c.alarms {
|
||||
lines << format_alarm(alarm)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// Converts an event to iCalendar format
|
||||
pub fn to_ical_event(event calbox.Event) string {
|
||||
mut lines := []string{}
|
||||
lines << 'BEGIN:VEVENT'
|
||||
|
||||
// Add common properties
|
||||
lines << format_component(event.CalendarComponent)
|
||||
|
||||
// Add event-specific properties
|
||||
lines << format_datetime_property('DTSTART', map[string]string{}, event.start_time)
|
||||
|
||||
if end := event.end_time {
|
||||
lines << format_datetime_property('DTEND', map[string]string{}, end)
|
||||
} else if dur := event.duration {
|
||||
lines << format_property('DURATION', map[string]string{}, dur)
|
||||
}
|
||||
|
||||
lines << 'TRANSP:${event.transp.str()}'
|
||||
|
||||
if rule := event.rrule {
|
||||
lines << format_rrule(rule)
|
||||
}
|
||||
|
||||
for ts in event.rdate {
|
||||
lines << 'RDATE:${format_datetime(ts)}'
|
||||
}
|
||||
|
||||
for ts in event.exdate {
|
||||
lines << 'EXDATE:${format_datetime(ts)}'
|
||||
}
|
||||
|
||||
for attendee in event.attendees {
|
||||
lines << format_attendee(attendee)
|
||||
}
|
||||
|
||||
if organizer := event.organizer {
|
||||
lines << format_attendee(organizer).replace('ATTENDEE:', 'ORGANIZER:')
|
||||
}
|
||||
|
||||
lines << 'END:VEVENT'
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
// Converts a todo to iCalendar format
|
||||
pub fn to_ical_todo(todo calbox.Todo) string {
|
||||
mut lines := []string{}
|
||||
lines << 'BEGIN:VTODO'
|
||||
|
||||
// Add common properties
|
||||
lines << format_component(todo.CalendarComponent)
|
||||
|
||||
// Add todo-specific properties
|
||||
if start := todo.start_time {
|
||||
lines << format_datetime_property('DTSTART', map[string]string{}, start)
|
||||
}
|
||||
|
||||
if due := todo.due_time {
|
||||
lines << format_datetime_property('DUE', map[string]string{}, due)
|
||||
}
|
||||
|
||||
if dur := todo.duration {
|
||||
lines << format_property('DURATION', map[string]string{}, dur)
|
||||
}
|
||||
|
||||
if completed := todo.completed {
|
||||
lines << format_datetime_property('COMPLETED', map[string]string{}, completed)
|
||||
}
|
||||
|
||||
if percent := todo.percent {
|
||||
lines << 'PERCENT-COMPLETE:${percent}'
|
||||
}
|
||||
|
||||
if rule := todo.rrule {
|
||||
lines << format_rrule(rule)
|
||||
}
|
||||
|
||||
for attendee in todo.attendees {
|
||||
lines << format_attendee(attendee)
|
||||
}
|
||||
|
||||
if organizer := todo.organizer {
|
||||
lines << format_attendee(organizer).replace('ATTENDEE:', 'ORGANIZER:')
|
||||
}
|
||||
|
||||
lines << 'END:VTODO'
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
// Converts a journal entry to iCalendar format
|
||||
pub fn to_ical_journal(journal calbox.Journal) string {
|
||||
mut lines := []string{}
|
||||
lines << 'BEGIN:VJOURNAL'
|
||||
|
||||
// Add common properties
|
||||
lines << format_component(journal.CalendarComponent)
|
||||
|
||||
// Add journal-specific properties
|
||||
lines << 'DTSTART:${format_datetime(journal.start_time)}'
|
||||
|
||||
for attendee in journal.attendees {
|
||||
lines << format_attendee(attendee)
|
||||
}
|
||||
|
||||
if organizer := journal.organizer {
|
||||
lines << format_attendee(organizer).replace('ATTENDEE:', 'ORGANIZER:')
|
||||
}
|
||||
|
||||
lines << 'END:VJOURNAL'
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
// Converts a calendar object to iCalendar format
|
||||
pub fn to_ical(obj calbox.CalendarObject) string {
|
||||
mut lines := []string{}
|
||||
lines << 'BEGIN:VCALENDAR'
|
||||
lines << 'VERSION:2.0'
|
||||
lines << 'PRODID:-//HeroLib//CalDAV Client//EN'
|
||||
|
||||
match obj.comp_type {
|
||||
'VEVENT' {
|
||||
if event := obj.event {
|
||||
lines << to_ical_event(event)
|
||||
}
|
||||
}
|
||||
'VTODO' {
|
||||
if todo := obj.todo {
|
||||
lines << to_ical_todo(todo)
|
||||
}
|
||||
}
|
||||
'VJOURNAL' {
|
||||
if journal := obj.journal {
|
||||
lines << to_ical_journal(journal)
|
||||
}
|
||||
}
|
||||
else {}
|
||||
}
|
||||
|
||||
lines << 'END:VCALENDAR'
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
module caldav
|
||||
|
||||
import calendar.calbox
|
||||
|
||||
fn test_format_event() {
|
||||
// Create a test event with all fields
|
||||
event := calbox.Event{
|
||||
CalendarComponent: calbox.CalendarComponent{
|
||||
uid: 'test@example.com'
|
||||
created: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
modified: 1708074000
|
||||
summary: 'Test Event'
|
||||
description: 'Test Description'
|
||||
categories: ['Work', 'Meeting']
|
||||
status: .confirmed
|
||||
class: .public
|
||||
location: 'Conference Room'
|
||||
alarms: [
|
||||
calbox.Alarm{
|
||||
action: .display
|
||||
trigger: '-PT15M'
|
||||
description: 'Meeting starts in 15 minutes'
|
||||
},
|
||||
]
|
||||
}
|
||||
start_time: 1708074000
|
||||
end_time: 1708077600 // 1 hour later
|
||||
transp: .opaque
|
||||
attendees: [
|
||||
calbox.Attendee{
|
||||
email: 'john@example.com'
|
||||
name: 'John Doe'
|
||||
role: .req_participant
|
||||
partstat: .accepted
|
||||
rsvp: true
|
||||
},
|
||||
]
|
||||
organizer: calbox.Attendee{
|
||||
email: 'boss@example.com'
|
||||
name: 'The Boss'
|
||||
role: .chair
|
||||
partstat: .accepted
|
||||
}
|
||||
}
|
||||
|
||||
obj := calbox.CalendarObject{
|
||||
comp_type: 'VEVENT'
|
||||
event: event
|
||||
}
|
||||
|
||||
ical := to_ical(obj)
|
||||
|
||||
// Verify required fields
|
||||
assert ical.contains('BEGIN:VCALENDAR')
|
||||
assert ical.contains('VERSION:2.0')
|
||||
assert ical.contains('BEGIN:VEVENT')
|
||||
assert ical.contains('UID:test@example.com')
|
||||
assert ical.contains('DTSTART:20240216T100000Z')
|
||||
assert ical.contains('DTEND:20240216T110000Z')
|
||||
assert ical.contains('SUMMARY:Test Event')
|
||||
assert ical.contains('STATUS:CONFIRMED')
|
||||
assert ical.contains('CLASS:PUBLIC')
|
||||
assert ical.contains('TRANSP:OPAQUE')
|
||||
assert ical.contains('END:VEVENT')
|
||||
assert ical.contains('END:VCALENDAR')
|
||||
|
||||
// Parse back
|
||||
parsed := from_ical(ical)!
|
||||
assert parsed.comp_type == 'VEVENT'
|
||||
|
||||
if e := parsed.event {
|
||||
assert e.uid == event.uid
|
||||
assert e.summary == event.summary
|
||||
assert e.start_time == event.start_time
|
||||
assert e.end_time? == event.end_time?
|
||||
assert e.status == event.status
|
||||
assert e.class == event.class
|
||||
assert e.transp == event.transp
|
||||
assert e.attendees.len == event.attendees.len
|
||||
assert e.attendees[0].role == event.attendees[0].role
|
||||
assert e.attendees[0].partstat == event.attendees[0].partstat
|
||||
assert e.organizer?.role == event.organizer?.role
|
||||
} else {
|
||||
assert false, 'Failed to parse event'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_format_recurring_event() {
|
||||
// Create a recurring event
|
||||
event := calbox.Event{
|
||||
CalendarComponent: calbox.CalendarComponent{
|
||||
uid: 'recurring@example.com'
|
||||
created: 1708074000
|
||||
summary: 'Daily Meeting'
|
||||
status: .confirmed
|
||||
class: .public
|
||||
}
|
||||
start_time: 1708074000
|
||||
duration: 'PT1H'
|
||||
transp: .opaque
|
||||
rrule: calbox.RecurrenceRule{
|
||||
frequency: .daily
|
||||
interval: 1
|
||||
count: 5
|
||||
by_day: ['MO', 'WE', 'FR']
|
||||
}
|
||||
}
|
||||
|
||||
obj := calbox.CalendarObject{
|
||||
comp_type: 'VEVENT'
|
||||
event: event
|
||||
}
|
||||
|
||||
ical := to_ical(obj)
|
||||
|
||||
// Verify recurrence fields
|
||||
assert ical.contains('RRULE:FREQ=DAILY;INTERVAL=1;COUNT=5;BYDAY=MO,WE,FR')
|
||||
assert ical.contains('DURATION:PT1H')
|
||||
|
||||
// Parse back
|
||||
parsed := from_ical(ical)!
|
||||
if e := parsed.event {
|
||||
assert e.duration? == event.duration?
|
||||
if rule := e.rrule {
|
||||
assert rule.frequency == .daily
|
||||
assert rule.interval == 1
|
||||
assert rule.count? == 5
|
||||
assert rule.by_day == ['MO', 'WE', 'FR']
|
||||
} else {
|
||||
assert false, 'Failed to parse recurrence rule'
|
||||
}
|
||||
} else {
|
||||
assert false, 'Failed to parse event'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_format_todo() {
|
||||
// Create a todo
|
||||
todo := calbox.Todo{
|
||||
CalendarComponent: calbox.CalendarComponent{
|
||||
uid: 'todo@example.com'
|
||||
created: 1708074000
|
||||
summary: 'Test Todo'
|
||||
status: .needs_action
|
||||
class: .private
|
||||
}
|
||||
due_time: 1708160400 // Next day
|
||||
percent: 0
|
||||
}
|
||||
|
||||
obj := calbox.CalendarObject{
|
||||
comp_type: 'VTODO'
|
||||
todo: todo
|
||||
}
|
||||
|
||||
ical := to_ical(obj)
|
||||
|
||||
// Verify todo fields
|
||||
assert ical.contains('BEGIN:VTODO')
|
||||
assert ical.contains('DUE:20240217T100000Z')
|
||||
assert ical.contains('PERCENT-COMPLETE:0')
|
||||
assert ical.contains('STATUS:NEEDS-ACTION')
|
||||
assert ical.contains('CLASS:PRIVATE')
|
||||
|
||||
// Parse back
|
||||
parsed := from_ical(ical)!
|
||||
if t := parsed.todo {
|
||||
assert t.uid == todo.uid
|
||||
assert t.summary == todo.summary
|
||||
assert t.due_time? == todo.due_time?
|
||||
assert t.percent? == todo.percent?
|
||||
assert t.status == todo.status
|
||||
assert t.class == todo.class
|
||||
} else {
|
||||
assert false, 'Failed to parse todo'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_format_journal() {
|
||||
// Create a journal entry
|
||||
journal := calbox.Journal{
|
||||
CalendarComponent: calbox.CalendarComponent{
|
||||
uid: 'journal@example.com'
|
||||
created: 1708074000
|
||||
summary: 'Test Journal'
|
||||
description: 'Today we discussed...'
|
||||
categories: ['Notes', 'Work']
|
||||
status: .draft
|
||||
class: .confidential
|
||||
}
|
||||
start_time: 1708074000
|
||||
}
|
||||
|
||||
obj := calbox.CalendarObject{
|
||||
comp_type: 'VJOURNAL'
|
||||
journal: journal
|
||||
}
|
||||
|
||||
ical := to_ical(obj)
|
||||
|
||||
// Verify journal fields
|
||||
assert ical.contains('BEGIN:VJOURNAL')
|
||||
assert ical.contains('DTSTART:20240216T100000Z')
|
||||
assert ical.contains('CATEGORIES:Notes,Work')
|
||||
assert ical.contains('STATUS:DRAFT')
|
||||
assert ical.contains('CLASS:CONFIDENTIAL')
|
||||
|
||||
// Parse back
|
||||
parsed := from_ical(ical)!
|
||||
if j := parsed.journal {
|
||||
assert j.uid == journal.uid
|
||||
assert j.summary == journal.summary
|
||||
assert j.description == journal.description
|
||||
assert j.categories == journal.categories
|
||||
assert j.start_time == journal.start_time
|
||||
assert j.status == journal.status
|
||||
assert j.class == journal.class
|
||||
} else {
|
||||
assert false, 'Failed to parse journal'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_parse_attendees() {
|
||||
ical := 'BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:test@example.com
|
||||
DTSTAMP:20240216T100000Z
|
||||
DTSTART:20240216T100000Z
|
||||
DTEND:20240216T110000Z
|
||||
SUMMARY:Test Event
|
||||
ATTENDEE;CN=John Doe;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=TRUE:mailto:john@example.com
|
||||
ORGANIZER;CN=The Boss;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:boss@example.com
|
||||
END:VEVENT
|
||||
END:VCALENDAR'
|
||||
|
||||
obj := from_ical(ical)!
|
||||
if event := obj.event {
|
||||
assert event.attendees.len == 1
|
||||
attendee := event.attendees[0]
|
||||
assert attendee.email == 'john@example.com'
|
||||
assert attendee.name == 'John Doe'
|
||||
assert attendee.role == .req_participant
|
||||
assert attendee.partstat == .accepted
|
||||
assert attendee.rsvp == true
|
||||
|
||||
if org := event.organizer {
|
||||
assert org.email == 'boss@example.com'
|
||||
assert org.name == 'The Boss'
|
||||
assert org.role == .chair
|
||||
assert org.partstat == .accepted
|
||||
} else {
|
||||
assert false, 'Failed to parse organizer'
|
||||
}
|
||||
} else {
|
||||
assert false, 'Failed to parse event'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_parse_alarms() {
|
||||
ical := 'BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:test@example.com
|
||||
DTSTAMP:20240216T100000Z
|
||||
DTSTART:20240216T100000Z
|
||||
DTEND:20240216T110000Z
|
||||
SUMMARY:Test Event
|
||||
BEGIN:VALARM
|
||||
ACTION:DISPLAY
|
||||
TRIGGER:-PT15M
|
||||
DESCRIPTION:Meeting starts in 15 minutes
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
END:VCALENDAR'
|
||||
|
||||
obj := from_ical(ical)!
|
||||
if event := obj.event {
|
||||
assert event.alarms.len == 1
|
||||
alarm := event.alarms[0]
|
||||
assert alarm.action == .display
|
||||
assert alarm.trigger == '-PT15M'
|
||||
assert alarm.description == 'Meeting starts in 15 minutes'
|
||||
} else {
|
||||
assert false, 'Failed to parse event'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_line_folding() {
|
||||
// Test long description that should be folded
|
||||
event := calbox.Event{
|
||||
CalendarComponent: calbox.CalendarComponent{
|
||||
uid: 'test@example.com'
|
||||
created: 1708074000
|
||||
summary: 'Test Event'
|
||||
description: 'This is a very long description that should be folded into multiple lines according to the iCalendar specification which states that lines longer than 75 characters should be folded'
|
||||
status: .confirmed
|
||||
class: .public
|
||||
}
|
||||
start_time: 1708074000
|
||||
end_time: 1708077600
|
||||
transp: .opaque
|
||||
}
|
||||
|
||||
obj := calbox.CalendarObject{
|
||||
comp_type: 'VEVENT'
|
||||
event: event
|
||||
}
|
||||
|
||||
ical := to_ical(obj)
|
||||
lines := ical.split_into_lines()
|
||||
|
||||
// Verify no line is longer than 75 characters
|
||||
for line in lines {
|
||||
assert line.len <= 75, 'Line exceeds 75 characters: ${line}'
|
||||
}
|
||||
|
||||
// Parse back and verify description is reconstructed
|
||||
parsed := from_ical(ical)!
|
||||
if e := parsed.event {
|
||||
assert e.description == event.description
|
||||
} else {
|
||||
assert false, 'Failed to parse event'
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
module caldav
|
||||
|
||||
import calendar.calbox
|
||||
|
||||
// Handles MKCALENDAR request
|
||||
pub fn handle_mkcalendar(path string, props map[string]string, principal Principal) !&calbox.CalBox {
|
||||
// Check preconditions
|
||||
check_mkcalendar_preconditions(path, props)!
|
||||
|
||||
// Create calendar collection
|
||||
mut cal := calbox.new(props['displayname'] or { path.all_after_last('/') })
|
||||
|
||||
// Set properties
|
||||
if desc := props[calendar_description] {
|
||||
cal.description = desc
|
||||
}
|
||||
if tz := props[calendar_timezone] {
|
||||
cal.timezone = tz
|
||||
}
|
||||
if components := get_prop_array(props, supported_calendar_component_set) {
|
||||
cal.supported_components = components
|
||||
}
|
||||
|
||||
// Set ACL
|
||||
cal.acl = new_acl()
|
||||
cal.acl.add_entry(principal, [admin], false, true)
|
||||
|
||||
return cal
|
||||
}
|
||||
|
||||
// Handles PUT request
|
||||
pub fn handle_put(cal &calbox.CalBox, obj calbox.CalendarObject, principal Principal) !bool {
|
||||
// Check privileges
|
||||
if !cal.acl.can_write(principal) {
|
||||
return error(err_no_privilege)
|
||||
}
|
||||
|
||||
// Check preconditions
|
||||
check_put_preconditions(cal, obj)!
|
||||
|
||||
// Add/update object
|
||||
cal.put(obj)!
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Handles DELETE request
|
||||
pub fn handle_delete(cal &calbox.CalBox, uid string, principal Principal) !bool {
|
||||
// Check privileges
|
||||
if !cal.acl.can_unbind(principal) {
|
||||
return error(err_no_privilege)
|
||||
}
|
||||
|
||||
// Delete object
|
||||
cal.delete(uid)!
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Handles COPY request
|
||||
pub fn handle_copy(src_cal &calbox.CalBox, dst_cal &calbox.CalBox, uid string, principal Principal) !bool {
|
||||
// Check source privileges
|
||||
if !src_cal.acl.can_read(principal) {
|
||||
return error(err_no_privilege)
|
||||
}
|
||||
|
||||
// Check destination privileges
|
||||
if !dst_cal.acl.can_bind(principal) {
|
||||
return error(err_no_privilege)
|
||||
}
|
||||
|
||||
// Get source object
|
||||
obj := src_cal.get_by_uid(uid) or { return error(err_resource_not_found) }
|
||||
|
||||
// Check preconditions
|
||||
check_copy_move_preconditions(src_cal, dst_cal, obj)!
|
||||
|
||||
// Copy object
|
||||
dst_cal.put(obj)!
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Handles MOVE request
|
||||
pub fn handle_move(src_cal &calbox.CalBox, dst_cal &calbox.CalBox, uid string, principal Principal) !bool {
|
||||
// Check source privileges
|
||||
if !src_cal.acl.can_unbind(principal) {
|
||||
return error(err_no_privilege)
|
||||
}
|
||||
|
||||
// Check destination privileges
|
||||
if !dst_cal.acl.can_bind(principal) {
|
||||
return error(err_no_privilege)
|
||||
}
|
||||
|
||||
// Get source object
|
||||
obj := src_cal.get_by_uid(uid) or { return error(err_resource_not_found) }
|
||||
|
||||
// Check preconditions
|
||||
check_copy_move_preconditions(src_cal, dst_cal, obj)!
|
||||
|
||||
// Move object
|
||||
dst_cal.put(obj)!
|
||||
src_cal.delete(uid)!
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Handles PROPFIND request
|
||||
pub fn handle_propfind(cal &calbox.CalBox, props []string, principal Principal) !map[string]string {
|
||||
// Check privileges
|
||||
if !cal.acl.can_read(principal) {
|
||||
return error(err_no_privilege)
|
||||
}
|
||||
|
||||
mut result := map[string]string{}
|
||||
|
||||
// Get requested properties
|
||||
for prop in props {
|
||||
if value := get_prop_string(cal.props, prop) {
|
||||
result[prop] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Handles PROPPATCH request
|
||||
pub fn handle_proppatch(mut cal calbox.CalBox, props map[string]string, principal Principal) !bool {
|
||||
// Check privileges
|
||||
if !cal.acl.can_write_props(principal) {
|
||||
return error(err_no_privilege)
|
||||
}
|
||||
|
||||
// Validate and set properties
|
||||
for name, value in props {
|
||||
match name {
|
||||
calendar_timezone { validate_calendar_timezone(value)! }
|
||||
supported_calendar_component_set { validate_supported_component_set(value.split(','))! }
|
||||
else {}
|
||||
}
|
||||
set_prop(mut cal.props, name, value)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
module caldav
|
||||
|
||||
import calendar.calbox
|
||||
|
||||
// Checks preconditions for MKCALENDAR
|
||||
pub fn check_mkcalendar_preconditions(path string, props map[string]string) !bool {
|
||||
// Resource must not exist
|
||||
if resource_exists(path) {
|
||||
return error(err_resource_must_be_null)
|
||||
}
|
||||
|
||||
// Valid calendar timezone
|
||||
if tz := props[calendar_timezone] {
|
||||
validate_calendar_timezone(tz)!
|
||||
}
|
||||
|
||||
// Valid component set
|
||||
if comp_set := get_prop_array(props, supported_calendar_component_set) {
|
||||
validate_supported_component_set(comp_set)!
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Checks preconditions for PUT
|
||||
pub fn check_put_preconditions(cal &calbox.CalBox, obj calbox.CalendarObject) !bool {
|
||||
// Supported calendar data
|
||||
validate_calendar_data('text/calendar', '2.0')!
|
||||
|
||||
// Valid calendar data
|
||||
if !is_valid_calendar_data(obj) {
|
||||
return error(err_valid_calendar_data)
|
||||
}
|
||||
|
||||
// Valid calendar object
|
||||
if !is_valid_calendar_object(obj) {
|
||||
return error(err_valid_calendar_object)
|
||||
}
|
||||
|
||||
// Supported component
|
||||
if !cal.supports_component(obj.comp_type) {
|
||||
return error(err_supported_component)
|
||||
}
|
||||
|
||||
// Resource size
|
||||
if max_size := cal.max_resource_size {
|
||||
if obj.size() > max_size {
|
||||
return error('Resource size exceeds maximum allowed')
|
||||
}
|
||||
}
|
||||
|
||||
// Date range
|
||||
if !check_date_range(cal, obj) {
|
||||
return error('Date/time outside allowed range')
|
||||
}
|
||||
|
||||
// Instance count
|
||||
if !check_instance_count(cal, obj) {
|
||||
return error('Too many recurrence instances')
|
||||
}
|
||||
|
||||
// Attendee count
|
||||
if !check_attendee_count(cal, obj) {
|
||||
return error('Too many attendees')
|
||||
}
|
||||
|
||||
// UID conflict
|
||||
if existing := cal.find_by_uid(obj.uid()) {
|
||||
if existing.uid() != obj.uid() {
|
||||
return error_with_href(err_uid_conflict, existing.href())
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Checks preconditions for COPY/MOVE
|
||||
pub fn check_copy_move_preconditions(src_cal &calbox.CalBox, dst_cal &calbox.CalBox, obj calbox.CalendarObject) !bool {
|
||||
// Valid calendar collection location
|
||||
if !is_valid_calendar_location(dst_cal.path) {
|
||||
return error(err_calendar_collection_location)
|
||||
}
|
||||
|
||||
// Check PUT preconditions on destination
|
||||
check_put_preconditions(dst_cal, obj)!
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
fn resource_exists(path string) bool {
|
||||
// TODO: Implement resource existence check
|
||||
return false
|
||||
}
|
||||
|
||||
fn is_valid_calendar_data(obj calbox.CalendarObject) bool {
|
||||
// TODO: Implement calendar data validation
|
||||
return true
|
||||
}
|
||||
|
||||
fn is_valid_calendar_object(obj calbox.CalendarObject) bool {
|
||||
// TODO: Implement calendar object validation
|
||||
return true
|
||||
}
|
||||
|
||||
fn is_valid_calendar_location(path string) bool {
|
||||
// TODO: Implement calendar location validation
|
||||
return true
|
||||
}
|
||||
|
||||
fn check_date_range(cal &calbox.CalBox, obj calbox.CalendarObject) bool {
|
||||
// TODO: Implement date range check
|
||||
return true
|
||||
}
|
||||
|
||||
fn check_instance_count(cal &calbox.CalBox, obj calbox.CalendarObject) bool {
|
||||
// TODO: Implement instance count check
|
||||
return true
|
||||
}
|
||||
|
||||
fn check_attendee_count(cal &calbox.CalBox, obj calbox.CalendarObject) bool {
|
||||
// TODO: Implement attendee count check
|
||||
return true
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
module caldav
|
||||
|
||||
// CalDAV property names
|
||||
pub const calendar_description = 'calendar-description'
|
||||
pub const calendar_timezone = 'calendar-timezone'
|
||||
pub const supported_calendar_component_set = 'supported-calendar-component-set'
|
||||
pub const supported_calendar_data = 'supported-calendar-data'
|
||||
pub const max_resource_size = 'max-resource-size'
|
||||
pub const min_date_time = 'min-date-time'
|
||||
pub const max_date_time = 'max-date-time'
|
||||
pub const max_instances = 'max-instances'
|
||||
pub const max_attendees_per_instance = 'max-attendees-per-instance'
|
||||
pub const calendar_home_set = 'calendar-home-set'
|
||||
|
||||
// Property validation errors
|
||||
pub const err_invalid_timezone = 'Invalid timezone: must be a valid iCalendar object containing a single VTIMEZONE component'
|
||||
pub const err_invalid_component_set = 'Invalid component set: must contain at least one component type'
|
||||
pub const err_invalid_calendar_data = 'Invalid calendar data: must specify a supported media type'
|
||||
pub const err_invalid_resource_size = 'Invalid resource size: must be a positive integer'
|
||||
pub const err_invalid_date_time = 'Invalid date/time: must be a valid UTC date-time value'
|
||||
pub const err_invalid_instances = 'Invalid instances value: must be a positive integer'
|
||||
pub const err_invalid_attendees = 'Invalid attendees value: must be a positive integer'
|
||||
|
||||
// Property validation functions
|
||||
|
||||
// Validates a calendar timezone property value
|
||||
pub fn validate_calendar_timezone(value string) ! {
|
||||
// TODO: Implement timezone validation
|
||||
// Should parse value as iCalendar and verify it contains exactly one VTIMEZONE
|
||||
if value.len == 0 {
|
||||
return error(err_invalid_timezone)
|
||||
}
|
||||
}
|
||||
|
||||
// Validates a supported calendar component set property value
|
||||
pub fn validate_supported_component_set(components []string) ! {
|
||||
if components.len == 0 {
|
||||
return error(err_invalid_component_set)
|
||||
}
|
||||
|
||||
valid := ['VEVENT', 'VTODO', 'VJOURNAL', 'VFREEBUSY']
|
||||
for comp in components {
|
||||
if comp !in valid {
|
||||
return error(err_invalid_component_set)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validates a supported calendar data property value
|
||||
pub fn validate_calendar_data(content_type string, version string) ! {
|
||||
if content_type != 'text/calendar' || version != '2.0' {
|
||||
return error(err_invalid_calendar_data)
|
||||
}
|
||||
}
|
||||
|
||||
// Validates a max resource size property value
|
||||
pub fn validate_max_resource_size(size int) ! {
|
||||
if size <= 0 {
|
||||
return error(err_invalid_resource_size)
|
||||
}
|
||||
}
|
||||
|
||||
// Validates a min/max date time property value
|
||||
pub fn validate_date_time(value string) ! {
|
||||
// TODO: Implement UTC date-time validation
|
||||
if value.len == 0 {
|
||||
return error(err_invalid_date_time)
|
||||
}
|
||||
}
|
||||
|
||||
// Validates a max instances property value
|
||||
pub fn validate_max_instances(count int) ! {
|
||||
if count <= 0 {
|
||||
return error(err_invalid_instances)
|
||||
}
|
||||
}
|
||||
|
||||
// Validates a max attendees per instance property value
|
||||
pub fn validate_max_attendees(count int) ! {
|
||||
if count <= 0 {
|
||||
return error(err_invalid_attendees)
|
||||
}
|
||||
}
|
||||
|
||||
// Property value getters/setters
|
||||
|
||||
// Gets a property value as string
|
||||
pub fn get_prop_string(props map[string]string, name string) ?string {
|
||||
return props[name]
|
||||
}
|
||||
|
||||
// Gets a property value as int
|
||||
pub fn get_prop_int(props map[string]string, name string) ?int {
|
||||
if value := props[name] {
|
||||
return value.int()
|
||||
}
|
||||
return none
|
||||
}
|
||||
|
||||
// Gets a property value as string array
|
||||
pub fn get_prop_array(props map[string]string, name string) ?[]string {
|
||||
if value := props[name] {
|
||||
return value.split(',')
|
||||
}
|
||||
return none
|
||||
}
|
||||
|
||||
// Sets a property value
|
||||
pub fn set_prop(mut props map[string]string, name string, value string) {
|
||||
props[name] = value
|
||||
}
|
||||
|
||||
// Removes a property
|
||||
pub fn remove_prop(mut props map[string]string, name string) {
|
||||
props.delete(name)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
module caldav
|
||||
|
||||
import calendar.calbox
|
||||
|
||||
// Represents a CalDAV server instance
|
||||
pub struct Server {
|
||||
mut:
|
||||
collections map[string]&calbox.CalBox // Map of calendar collections by path
|
||||
}
|
||||
|
||||
// Creates a new CalDAV server instance
|
||||
pub fn new() &Server {
|
||||
return &Server{
|
||||
collections: map[string]&calbox.CalBox{}
|
||||
}
|
||||
}
|
||||
|
||||
// Adds a calendar collection to the server
|
||||
pub fn (mut s Server) add_collection(path string, cal &calbox.CalBox) {
|
||||
s.collections[path] = cal
|
||||
}
|
||||
|
||||
// Gets a calendar collection by path
|
||||
pub fn (s Server) get_collection(path string) ?&calbox.CalBox {
|
||||
return s.collections[path] or { none }
|
||||
}
|
||||
|
||||
// Handles a MKCALENDAR request
|
||||
pub fn (mut s Server) handle_mkcalendar(path string, props map[string]string) !&calbox.CalBox {
|
||||
// Create new calendar collection
|
||||
mut cal := calbox.new(props['displayname'] or { path.all_after_last('/') })
|
||||
|
||||
// Set optional properties
|
||||
if desc := props['calendar-description'] {
|
||||
cal.description = desc
|
||||
}
|
||||
if tz := props['calendar-timezone'] {
|
||||
cal.timezone = tz
|
||||
}
|
||||
if components := props['supported-calendar-component-set'] {
|
||||
cal.supported_components = components.split(',')
|
||||
}
|
||||
|
||||
// Add to server
|
||||
s.add_collection(path, cal)
|
||||
|
||||
return cal
|
||||
}
|
||||
|
||||
// Handles a calendar-query REPORT request
|
||||
pub fn (s Server) handle_calendar_query(path string, filter CalendarQueryFilter) ![]calbox.CalendarObject {
|
||||
// Get calendar collection
|
||||
cal := s.get_collection(path) or { return error('Calendar not found') }
|
||||
|
||||
// Apply filter
|
||||
mut results := []calbox.CalendarObject{}
|
||||
|
||||
if filter.time_range != none {
|
||||
tr := filter.time_range or { return error('Invalid time range') }
|
||||
results = cal.find_by_time(calbox.TimeRange{
|
||||
start: tr.start
|
||||
end: tr.end
|
||||
})!
|
||||
} else {
|
||||
results = cal.list()!
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Handles a calendar-multiget REPORT request
|
||||
pub fn (s Server) handle_calendar_multiget(path string, hrefs []string) ![]calbox.CalendarObject {
|
||||
// Get calendar collection
|
||||
cal := s.get_collection(path) or { return error('Calendar not found') }
|
||||
|
||||
mut results := []calbox.CalendarObject{}
|
||||
|
||||
// Get requested resources
|
||||
for href in hrefs {
|
||||
obj_path := href.all_after(path)
|
||||
if obj := cal.get_by_uid(obj_path) {
|
||||
results << obj
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Handles a free-busy-query REPORT request
|
||||
pub fn (s Server) handle_freebusy_query(path string, tr calbox.TimeRange) ![]calbox.TimeRange {
|
||||
// Get calendar collection
|
||||
cal := s.get_collection(path) or { return error('Calendar not found') }
|
||||
|
||||
// Get free/busy info
|
||||
return cal.get_freebusy(tr)
|
||||
}
|
||||
|
||||
// Filter for calendar-query REPORT
|
||||
pub struct CalendarQueryFilter {
|
||||
pub mut:
|
||||
time_range ?calbox.TimeRange
|
||||
comp_type string
|
||||
prop_name string
|
||||
text_match string
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
module caldav
|
||||
|
||||
import calendar.calbox
|
||||
|
||||
// Report types
|
||||
pub const calendar_query = 'calendar-query'
|
||||
pub const calendar_multiget = 'calendar-multiget'
|
||||
pub const freebusy_query = 'free-busy-query'
|
||||
|
||||
// Report request parameters
|
||||
pub struct ReportParams {
|
||||
pub mut:
|
||||
depth int // Depth header value
|
||||
props []string // Properties to return
|
||||
filter CalendarQueryFilter // Query filter
|
||||
hrefs []string // Resource hrefs for multiget
|
||||
timezone string // Timezone for floating time conversion
|
||||
expand bool // Whether to expand recurrences
|
||||
limit_set bool // Whether to limit recurrence/freebusy set
|
||||
time_range ?calbox.TimeRange // Time range for limiting results
|
||||
}
|
||||
|
||||
// Report response
|
||||
pub struct ReportResponse {
|
||||
pub mut:
|
||||
status int // HTTP status code
|
||||
objects []calbox.CalendarObject // Calendar objects
|
||||
ranges []calbox.TimeRange // Free/busy ranges
|
||||
error ?CalDAVError // Error if any
|
||||
}
|
||||
|
||||
// Handles a calendar-query REPORT
|
||||
pub fn handle_calendar_query(cal &calbox.CalBox, params ReportParams) ReportResponse {
|
||||
mut response := ReportResponse{
|
||||
status: 207 // Multi-Status
|
||||
}
|
||||
|
||||
// Check privileges
|
||||
if !cal.acl.can_read(params.principal) {
|
||||
response.status = 403
|
||||
response.error = err_forbidden_error(err_no_privilege)
|
||||
return response
|
||||
}
|
||||
|
||||
// Apply filter
|
||||
objects := cal.find_by_filter(params.filter) or {
|
||||
response.status = 500
|
||||
response.error = new_error(500, err.str())
|
||||
return response
|
||||
}
|
||||
|
||||
// Apply time range if specified
|
||||
if tr := params.time_range {
|
||||
for obj in objects {
|
||||
if obj.overlaps(tr) {
|
||||
response.objects << obj
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response.objects = objects
|
||||
}
|
||||
|
||||
// Expand recurrences if requested
|
||||
if params.expand {
|
||||
response.objects = expand_recurrences(response.objects, params.time_range)
|
||||
}
|
||||
|
||||
// Limit recurrence set if requested
|
||||
if params.limit_set {
|
||||
response.objects = limit_recurrence_set(response.objects, params.time_range)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// Handles a calendar-multiget REPORT
|
||||
pub fn handle_calendar_multiget(cal &calbox.CalBox, params ReportParams) ReportResponse {
|
||||
mut response := ReportResponse{
|
||||
status: 207 // Multi-Status
|
||||
}
|
||||
|
||||
// Check privileges
|
||||
if !cal.acl.can_read(params.principal) {
|
||||
response.status = 403
|
||||
response.error = err_forbidden_error(err_no_privilege)
|
||||
return response
|
||||
}
|
||||
|
||||
// Get requested resources
|
||||
for href in params.hrefs {
|
||||
if obj := cal.get_by_href(href) {
|
||||
response.objects << obj
|
||||
}
|
||||
}
|
||||
|
||||
// Expand recurrences if requested
|
||||
if params.expand {
|
||||
response.objects = expand_recurrences(response.objects, params.time_range)
|
||||
}
|
||||
|
||||
// Limit recurrence set if requested
|
||||
if params.limit_set {
|
||||
response.objects = limit_recurrence_set(response.objects, params.time_range)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// Handles a free-busy-query REPORT
|
||||
pub fn handle_freebusy_query(cal &calbox.CalBox, params ReportParams) ReportResponse {
|
||||
mut response := ReportResponse{
|
||||
status: 200 // OK
|
||||
}
|
||||
|
||||
// Check privileges
|
||||
if !cal.acl.can_read_freebusy(params.principal) {
|
||||
response.status = 403
|
||||
response.error = err_forbidden_error(err_no_privilege)
|
||||
return response
|
||||
}
|
||||
|
||||
// Get time range
|
||||
tr := params.time_range or {
|
||||
response.status = 400
|
||||
response.error = new_error(400, 'Missing time range')
|
||||
return response
|
||||
}
|
||||
|
||||
// Get free/busy ranges
|
||||
ranges := cal.get_freebusy(tr) or {
|
||||
response.status = 500
|
||||
response.error = new_error(500, err.str())
|
||||
return response
|
||||
}
|
||||
|
||||
response.ranges = ranges
|
||||
return response
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
fn expand_recurrences(objects []calbox.CalendarObject, tr ?calbox.TimeRange) []calbox.CalendarObject {
|
||||
mut expanded := []calbox.CalendarObject{}
|
||||
|
||||
for obj in objects {
|
||||
if obj.is_recurring() {
|
||||
if range := tr {
|
||||
expanded << obj.expand(range)
|
||||
} else {
|
||||
expanded << obj.expand_all()
|
||||
}
|
||||
} else {
|
||||
expanded << obj
|
||||
}
|
||||
}
|
||||
|
||||
return expanded
|
||||
}
|
||||
|
||||
fn limit_recurrence_set(objects []calbox.CalendarObject, tr ?calbox.TimeRange) []calbox.CalendarObject {
|
||||
if tr == none {
|
||||
return objects
|
||||
}
|
||||
|
||||
mut limited := []calbox.CalendarObject{}
|
||||
range := tr or { return objects }
|
||||
|
||||
for obj in objects {
|
||||
if obj.overlaps(range) {
|
||||
limited << obj
|
||||
}
|
||||
}
|
||||
|
||||
return limited
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,166 +0,0 @@
|
||||
module caldav
|
||||
|
||||
import calendar.calbox
|
||||
import encoding.xml
|
||||
|
||||
// XML namespace for CalDAV
|
||||
const caldav_ns = 'urn:ietf:params:xml:ns:caldav'
|
||||
|
||||
// Parses a MKCALENDAR request body
|
||||
pub fn parse_mkcalendar(body string) !map[string]string {
|
||||
doc := xml.parse(body) or { return error('Invalid XML') }
|
||||
|
||||
mut props := map[string]string{}
|
||||
|
||||
// Find set element
|
||||
set := doc.get_element('set') or { return error('Missing set element') }
|
||||
|
||||
// Find prop element
|
||||
prop := set.get_element('prop') or { return error('Missing prop element') }
|
||||
|
||||
// Parse properties
|
||||
for child in prop.children {
|
||||
match child.name {
|
||||
'displayname' {
|
||||
props['displayname'] = child.text()
|
||||
}
|
||||
'calendar-description' {
|
||||
props['calendar-description'] = child.text()
|
||||
}
|
||||
'calendar-timezone' {
|
||||
props['calendar-timezone'] = child.text()
|
||||
}
|
||||
'supported-calendar-component-set' {
|
||||
mut components := []string{}
|
||||
for comp in child.children {
|
||||
if comp.name == 'comp' {
|
||||
if name := comp.attributes['name'] {
|
||||
components << name
|
||||
}
|
||||
}
|
||||
}
|
||||
props['supported-calendar-component-set'] = components.join(',')
|
||||
}
|
||||
else {}
|
||||
}
|
||||
}
|
||||
|
||||
return props
|
||||
}
|
||||
|
||||
// Parses a calendar-query REPORT request body
|
||||
pub fn parse_calendar_query(body string) !CalendarQueryFilter {
|
||||
doc := xml.parse(body) or { return error('Invalid XML') }
|
||||
|
||||
mut filter := CalendarQueryFilter{}
|
||||
|
||||
// Find filter element
|
||||
filter_elem := doc.get_element('filter') or { return error('Missing filter element') }
|
||||
|
||||
// Parse filter
|
||||
if comp := filter_elem.get_element('comp-filter') {
|
||||
filter.comp_type = comp.attributes['name'] or { '' }
|
||||
|
||||
// Check for time-range
|
||||
if tr := comp.get_element('time-range') {
|
||||
start := tr.attributes['start'] or { '' }
|
||||
end := tr.attributes['end'] or { '' }
|
||||
if start != '' && end != '' {
|
||||
filter.time_range = calbox.TimeRange{
|
||||
start: calbox.parse_datetime(start)!
|
||||
end: calbox.parse_datetime(end)!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for property filter
|
||||
if prop := comp.get_element('prop-filter') {
|
||||
filter.prop_name = prop.attributes['name'] or { '' }
|
||||
|
||||
// Check for text match
|
||||
if tm := prop.get_element('text-match') {
|
||||
filter.text_match = tm.text()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filter
|
||||
}
|
||||
|
||||
// Parses a calendar-multiget REPORT request body
|
||||
pub fn parse_calendar_multiget(body string) ![]string {
|
||||
doc := xml.parse(body) or { return error('Invalid XML') }
|
||||
|
||||
mut hrefs := []string{}
|
||||
|
||||
// Find href elements
|
||||
for href in doc.find_all_elements('href') {
|
||||
hrefs << href.text()
|
||||
}
|
||||
|
||||
return hrefs
|
||||
}
|
||||
|
||||
// Parses a free-busy-query REPORT request body
|
||||
pub fn parse_freebusy_query(body string) !calbox.TimeRange {
|
||||
doc := xml.parse(body) or { return error('Invalid XML') }
|
||||
|
||||
// Find time-range element
|
||||
tr := doc.get_element('time-range') or { return error('Missing time-range element') }
|
||||
|
||||
start := tr.attributes['start'] or { return error('Missing start attribute') }
|
||||
end := tr.attributes['end'] or { return error('Missing end attribute') }
|
||||
|
||||
return calbox.TimeRange{
|
||||
start: calbox.parse_datetime(start)!
|
||||
end: calbox.parse_datetime(end)!
|
||||
}
|
||||
}
|
||||
|
||||
// Generates XML response for MKCALENDAR
|
||||
pub fn generate_mkcalendar_response() string {
|
||||
return '<?xml version="1.0" encoding="utf-8" ?>\n<mkcalendar-response xmlns="urn:ietf:params:xml:ns:caldav"/>'
|
||||
}
|
||||
|
||||
// Generates XML response for calendar-query REPORT
|
||||
pub fn generate_calendar_query_response(objects []calbox.CalendarObject) string {
|
||||
mut response := '<?xml version="1.0" encoding="utf-8" ?>\n<multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">\n'
|
||||
|
||||
for obj in objects {
|
||||
response += ' <response>\n'
|
||||
response += ' <href>${obj.uid}</href>\n'
|
||||
response += ' <propstat>\n'
|
||||
response += ' <prop>\n'
|
||||
response += ' <getetag>${obj.etag}</getetag>\n'
|
||||
response += ' <C:calendar-data>${obj.to_ical()}</C:calendar-data>\n'
|
||||
response += ' </prop>\n'
|
||||
response += ' <status>HTTP/1.1 200 OK</status>\n'
|
||||
response += ' </propstat>\n'
|
||||
response += ' </response>\n'
|
||||
}
|
||||
|
||||
response += '</multistatus>'
|
||||
return response
|
||||
}
|
||||
|
||||
// Generates XML response for calendar-multiget REPORT
|
||||
pub fn generate_calendar_multiget_response(objects []calbox.CalendarObject) string {
|
||||
// Same format as calendar-query response
|
||||
return generate_calendar_query_response(objects)
|
||||
}
|
||||
|
||||
// Generates XML response for free-busy-query REPORT
|
||||
pub fn generate_freebusy_response(ranges []calbox.TimeRange) string {
|
||||
mut response := 'BEGIN:VCALENDAR\r\n'
|
||||
response += 'VERSION:2.0\r\n'
|
||||
response += 'PRODID:-//Example Corp.//CalDAV Server//EN\r\n'
|
||||
response += 'BEGIN:VFREEBUSY\r\n'
|
||||
|
||||
for r in ranges {
|
||||
response += 'FREEBUSY:${calbox.format_datetime_utc(r.start)}/${calbox.format_datetime_utc(r.end)}\r\n'
|
||||
}
|
||||
|
||||
response += 'END:VFREEBUSY\r\n'
|
||||
response += 'END:VCALENDAR\r\n'
|
||||
return response
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,118 +0,0 @@
|
||||
# IMAP Server
|
||||
|
||||
A simple IMAP server implementation in V that supports basic mailbox operations.
|
||||
|
||||
## Features
|
||||
|
||||
- IMAP server implementation with persistent storage via mailbox module
|
||||
- Support for multiple mailboxes
|
||||
- Basic IMAP commands: LOGIN, SELECT, FETCH, STORE, LOGOUT
|
||||
- Message flags support (e.g. \Seen, \Flagged)
|
||||
- Concurrent client handling
|
||||
|
||||
## Usage
|
||||
|
||||
The server can be started with a simple function call:
|
||||
|
||||
```v
|
||||
import freeflowuniverse.herolib.servers.mail.imap
|
||||
import freeflowuniverse.herolib.servers.mail.mailbox
|
||||
|
||||
fn main() {
|
||||
// Create the mail server
|
||||
mut mailserver := mailbox.new_mail_server()
|
||||
|
||||
// Create the IMAP server wrapping the mail server
|
||||
mut imap_server := imap.new_server(mailserver)
|
||||
|
||||
// Start the IMAP server on port 143
|
||||
imap_server.start() or { panic(err) }
|
||||
}
|
||||
```
|
||||
|
||||
Save this to `example.v` and run with:
|
||||
|
||||
```bash
|
||||
v run example.v
|
||||
```
|
||||
|
||||
The server will start listening on port 143 (default IMAP port).
|
||||
|
||||
## Testing with an IMAP Client
|
||||
|
||||
You can test the server using any IMAP client. Here's an example using the `curl` command:
|
||||
|
||||
```bash
|
||||
# Connect and login (any username/password is accepted)
|
||||
curl "imap://localhost/" -u "user:pass" --ssl-reqd
|
||||
|
||||
# List messages in INBOX
|
||||
curl "imap://localhost/INBOX" -u "user:pass" --ssl-reqd
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
The server consists of two main components:
|
||||
|
||||
1. **Mailbox Module** (`mailbox/`): Core mail functionality
|
||||
- User account management
|
||||
- Mailbox operations (create, delete, list)
|
||||
- Message storage and retrieval
|
||||
- Message flag management
|
||||
- Search capabilities
|
||||
|
||||
2. **IMAP Server** (`imap/`): IMAP protocol implementation
|
||||
- TCP connection handling and session management
|
||||
- IMAP command processing
|
||||
- Maps IMAP operations to mailbox module functionality
|
||||
- Concurrent client support
|
||||
|
||||
## Supported Commands
|
||||
|
||||
- `CAPABILITY`: List server capabilities
|
||||
- `LOGIN`: Authenticate (accepts any credentials)
|
||||
- `SELECT`: Select a mailbox
|
||||
- `FETCH`: Retrieve message data
|
||||
- `STORE`: Update message flags
|
||||
- `LOGOUT`: End the session
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
C: A001 CAPABILITY
|
||||
S: * CAPABILITY IMAP4rev1 AUTH=PLAIN
|
||||
S: A001 OK CAPABILITY completed
|
||||
|
||||
C: A002 LOGIN user pass
|
||||
S: A002 OK LOGIN completed
|
||||
|
||||
C: A003 SELECT INBOX
|
||||
S: * FLAGS (\Answered \Flagged \Deleted \Seen \Draft)
|
||||
S: * 2 EXISTS
|
||||
S: A003 OK SELECT completed
|
||||
|
||||
C: A004 FETCH 1:* BODY[TEXT]
|
||||
S: * 1 FETCH (FLAGS (\Seen) BODY[TEXT] "Welcome to the IMAP server!")
|
||||
S: * 2 FETCH (FLAGS () BODY[TEXT] "This is an update.")
|
||||
S: A004 OK FETCH completed
|
||||
|
||||
C: A005 STORE 2 +FLAGS (\Seen)
|
||||
S: A005 OK STORE completed
|
||||
|
||||
C: A006 CAPABILITY
|
||||
S: * CAPABILITY IMAP4rev1 AUTH=PLAIN
|
||||
S: A006 OK CAPABILITY completed
|
||||
|
||||
C: A007 LOGOUT
|
||||
S: * BYE IMAP4rev1 Server logging out
|
||||
S: A007 OK LOGOUT completed
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The server runs on port 143, which typically requires root privileges. Make sure you have the necessary permissions.
|
||||
- This is a basic implementation for demonstration purposes. For production use, consider adding:
|
||||
- Proper authentication
|
||||
- Full IMAP command support
|
||||
- TLS encryption
|
||||
- Message parsing and MIME support
|
||||
@@ -1,45 +0,0 @@
|
||||
module imap
|
||||
|
||||
import net
|
||||
import io
|
||||
import freeflowuniverse.herolib.ui.console
|
||||
|
||||
// handle_authenticate processes the AUTHENTICATE command
|
||||
pub fn (mut self Session) handle_authenticate(tag string, parts []string) ! {
|
||||
if parts.len < 3 {
|
||||
self.conn.write('${tag} BAD AUTHENTICATE requires an authentication mechanism\r\n'.bytes())!
|
||||
return
|
||||
}
|
||||
auth_type := parts[2].to_upper()
|
||||
if auth_type == 'PLAIN' {
|
||||
// Send continuation request for credentials
|
||||
self.conn.write('+ \r\n'.bytes())!
|
||||
// Read base64 credentials
|
||||
creds := self.reader.read_line() or {
|
||||
match err.msg() {
|
||||
'closed' {
|
||||
console.print_debug('Client disconnected during authentication')
|
||||
return error('client disconnected during auth')
|
||||
}
|
||||
'EOF' {
|
||||
console.print_debug('Client ended connection during authentication (EOF)')
|
||||
return error('connection ended during auth')
|
||||
}
|
||||
else {
|
||||
eprintln('Connection read error during authentication: ${err}')
|
||||
return error('connection error during auth: ${err}')
|
||||
}
|
||||
}
|
||||
}
|
||||
if creds.len > 0 {
|
||||
// For demo purposes, accept any credentials
|
||||
// After successful auth, remove STARTTLS and LOGINDISABLED capabilities
|
||||
self.capabilities = ['IMAP4rev2', 'AUTH=PLAIN']
|
||||
self.conn.write('${tag} OK [CAPABILITY IMAP4rev2 AUTH=PLAIN] Authentication successful\r\n'.bytes())!
|
||||
} else {
|
||||
self.conn.write('${tag} NO Authentication failed\r\n'.bytes())!
|
||||
}
|
||||
} else {
|
||||
self.conn.write('${tag} NO [ALERT] Unsupported authentication mechanism\r\n'.bytes())!
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
module imap
|
||||
|
||||
import net
|
||||
|
||||
// handle_capability processes the CAPABILITY command
|
||||
// See RFC 3501 Section 6.1.1
|
||||
pub fn (mut self Session) handle_capability(tag string) ! {
|
||||
mut capabilities := []string{}
|
||||
|
||||
// IMAP4rev2 is required and must be included
|
||||
capabilities << 'IMAP4rev2'
|
||||
|
||||
// Required capabilities on cleartext ports
|
||||
if !self.tls_active {
|
||||
capabilities << 'STARTTLS'
|
||||
capabilities << 'LOGINDISABLED'
|
||||
}
|
||||
|
||||
// Required AUTH capability
|
||||
capabilities << 'AUTH=PLAIN'
|
||||
|
||||
// Send capabilities in untagged response
|
||||
// Note: IMAP4rev2 doesn't need to be first, but must be included
|
||||
self.conn.write('* CAPABILITY ${capabilities.join(' ')}\r\n'.bytes())!
|
||||
|
||||
// Send tagged OK response
|
||||
self.conn.write('${tag} OK CAPABILITY completed\r\n'.bytes())!
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
module imap
|
||||
|
||||
import net
|
||||
|
||||
// handle_close processes the CLOSE command
|
||||
// See RFC 3501 Section 6.4.1
|
||||
pub fn (mut self Session) handle_close(tag string) ! {
|
||||
// If no mailbox is selected, return error
|
||||
if self.mailbox == '' {
|
||||
self.conn.write('${tag} NO No mailbox selected\r\n'.bytes())!
|
||||
return
|
||||
}
|
||||
|
||||
// Get all messages in the mailbox
|
||||
messages := self.server.mailboxserver.message_list(self.username, self.mailbox)!
|
||||
|
||||
// Delete messages with \Deleted flag
|
||||
for msg in messages {
|
||||
if '\\Deleted' in msg.flags {
|
||||
self.server.mailboxserver.message_delete(self.username, self.mailbox, msg.uid) or {
|
||||
eprintln('Failed to delete message ${msg.uid}: ${err}')
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear selected mailbox
|
||||
self.mailbox = ''
|
||||
|
||||
self.conn.write('${tag} OK CLOSE completed\r\n'.bytes())!
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
module imap
|
||||
|
||||
import net
|
||||
import strconv
|
||||
|
||||
// handle_fetch processes the FETCH command
|
||||
// See RFC 3501 Section 6.4.5
|
||||
pub fn (mut self Session) handle_fetch(tag string, parts []string) ! {
|
||||
// Check if user is logged in
|
||||
if self.username == '' {
|
||||
self.conn.write('${tag} NO Must be logged in first\r\n'.bytes())!
|
||||
return error('Not logged in')
|
||||
}
|
||||
|
||||
if parts.len < 4 {
|
||||
self.conn.write('${tag} BAD FETCH requires a message sequence and data item\r\n'.bytes())!
|
||||
return
|
||||
}
|
||||
|
||||
sequence := parts[2]
|
||||
// Join remaining parts to handle parenthesized items
|
||||
data_items := parts[3..].join(' ').trim('()')
|
||||
|
||||
// Parse data items, handling quoted strings and parentheses
|
||||
mut items_to_fetch := []string{}
|
||||
mut current_item := ''
|
||||
mut in_brackets := false
|
||||
|
||||
for c in data_items {
|
||||
match c {
|
||||
`[` {
|
||||
in_brackets = true
|
||||
current_item += c.ascii_str()
|
||||
}
|
||||
`]` {
|
||||
in_brackets = false
|
||||
current_item += c.ascii_str()
|
||||
if current_item != '' {
|
||||
items_to_fetch << current_item.trim_space()
|
||||
current_item = ''
|
||||
}
|
||||
}
|
||||
` ` {
|
||||
if in_brackets {
|
||||
current_item += c.ascii_str()
|
||||
} else if current_item != '' {
|
||||
items_to_fetch << current_item.trim_space()
|
||||
current_item = ''
|
||||
}
|
||||
}
|
||||
else {
|
||||
current_item += c.ascii_str()
|
||||
}
|
||||
}
|
||||
}
|
||||
if current_item != '' {
|
||||
items_to_fetch << current_item.trim_space()
|
||||
}
|
||||
|
||||
// Convert to uppercase for matching
|
||||
items_to_fetch = items_to_fetch.map(it.to_upper())
|
||||
|
||||
// Get all messages in mailbox
|
||||
messages := self.server.mailboxserver.message_list(self.username, self.mailbox)!
|
||||
total_messages := messages.len
|
||||
|
||||
// Parse sequence range
|
||||
mut start_idx := 0
|
||||
mut end_idx := 0
|
||||
|
||||
if sequence == '1:*' {
|
||||
start_idx = 0
|
||||
end_idx = total_messages - 1
|
||||
} else if sequence.contains(':') {
|
||||
range_parts := sequence.split(':')
|
||||
if range_parts.len != 2 {
|
||||
self.conn.write('${tag} BAD Invalid sequence range\r\n'.bytes())!
|
||||
return
|
||||
}
|
||||
start_idx = strconv.atoi(range_parts[0]) or {
|
||||
self.conn.write('${tag} BAD Invalid sequence range start\r\n'.bytes())!
|
||||
return
|
||||
} - 1
|
||||
if range_parts[1] == '*' {
|
||||
end_idx = total_messages - 1
|
||||
} else {
|
||||
end_idx = strconv.atoi(range_parts[1]) or {
|
||||
self.conn.write('${tag} BAD Invalid sequence range end\r\n'.bytes())!
|
||||
return
|
||||
} - 1
|
||||
}
|
||||
} else {
|
||||
// Single message number
|
||||
start_idx = strconv.atoi(sequence) or {
|
||||
self.conn.write('${tag} BAD Invalid message number\r\n'.bytes())!
|
||||
return
|
||||
} - 1
|
||||
end_idx = start_idx
|
||||
}
|
||||
|
||||
if start_idx < 0 || end_idx >= total_messages || start_idx > end_idx {
|
||||
self.conn.write('${tag} NO Invalid message range\r\n'.bytes())!
|
||||
return
|
||||
}
|
||||
|
||||
// Process messages in range
|
||||
for i := start_idx; i <= end_idx; i++ {
|
||||
msg := messages[i]
|
||||
mut response := []string{}
|
||||
|
||||
// Always include UID in FETCH responses
|
||||
response << 'UID ${msg.uid}'
|
||||
|
||||
for item in items_to_fetch {
|
||||
match item {
|
||||
'FLAGS' {
|
||||
flags_str := if msg.flags.len > 0 {
|
||||
msg.flags.join(' ')
|
||||
} else {
|
||||
''
|
||||
}
|
||||
response << 'FLAGS (${flags_str})'
|
||||
}
|
||||
'INTERNALDATE' {
|
||||
response << 'INTERNALDATE "${msg.internal_date.str()}"'
|
||||
}
|
||||
'RFC822.SIZE' {
|
||||
response << 'RFC822.SIZE ${msg.body.len}'
|
||||
}
|
||||
'BODY[TEXT]' {
|
||||
// Mark message as seen unless using BODY.PEEK
|
||||
if !item.contains('.PEEK') {
|
||||
if '\\Seen' !in msg.flags {
|
||||
mut updated_msg := msg
|
||||
updated_msg.flags << '\\Seen'
|
||||
self.server.mailboxserver.message_set(self.username, self.mailbox,
|
||||
msg.uid, updated_msg) or {
|
||||
eprintln('Failed to update \\Seen flag: ${err}')
|
||||
}
|
||||
}
|
||||
}
|
||||
response << 'BODY[TEXT] {${msg.body.len}}\r\n${msg.body}'
|
||||
}
|
||||
'BODY[]', 'BODY.PEEK[]' {
|
||||
// Mark message as seen unless using BODY.PEEK
|
||||
if !item.contains('.PEEK') {
|
||||
if '\\Seen' !in msg.flags {
|
||||
mut updated_msg := msg
|
||||
updated_msg.flags << '\\Seen'
|
||||
self.server.mailboxserver.message_set(self.username, self.mailbox,
|
||||
msg.uid, updated_msg) or {
|
||||
eprintln('Failed to update \\Seen flag: ${err}')
|
||||
}
|
||||
}
|
||||
}
|
||||
// For BODY[], return the full message including headers
|
||||
mut full_msg := 'From: <>\r\n'
|
||||
full_msg += 'Subject: ${msg.subject}\r\n'
|
||||
full_msg += 'Date: ${msg.internal_date.str()}\r\n'
|
||||
full_msg += '\r\n' // Empty line between headers and body
|
||||
full_msg += msg.body
|
||||
response << 'BODY[] {${full_msg.len}}\r\n${full_msg}'
|
||||
}
|
||||
'BODY[HEADER]', 'BODY.PEEK[HEADER]' {
|
||||
// Return just the headers
|
||||
mut headers := 'From: <>\r\n'
|
||||
headers += 'Subject: ${msg.subject}\r\n'
|
||||
headers += 'Date: ${msg.internal_date.str()}\r\n'
|
||||
headers += '\r\n' // Empty line after headers
|
||||
response << 'BODY[HEADER] {${headers.len}}\r\n${headers}'
|
||||
}
|
||||
'ENVELOPE' {
|
||||
// Basic envelope with just subject for now
|
||||
response << 'ENVELOPE (NIL "${msg.subject}" NIL NIL NIL NIL NIL NIL NIL NIL)'
|
||||
}
|
||||
else {}
|
||||
}
|
||||
}
|
||||
|
||||
self.conn.write('* ${i + 1} FETCH (${response.join(' ')})\r\n'.bytes())!
|
||||
}
|
||||
self.conn.write('${tag} OK FETCH completed\r\n'.bytes())!
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
module imap
|
||||
|
||||
import net
|
||||
|
||||
// handle_list processes the LIST command
|
||||
// See RFC 3501 Section 6.3.9
|
||||
pub fn (mut self Session) handle_list(tag string, parts []string) ! {
|
||||
// Check if user is logged in
|
||||
if self.username == '' {
|
||||
self.conn.write('${tag} NO Must be logged in first\r\n'.bytes())!
|
||||
return error('Not logged in')
|
||||
}
|
||||
|
||||
if parts.len < 4 {
|
||||
self.conn.write('${tag} BAD LIST requires reference name and mailbox name\r\n'.bytes())!
|
||||
return
|
||||
}
|
||||
|
||||
reference := parts[2].trim('"')
|
||||
pattern := parts[3].trim('"')
|
||||
|
||||
// For now, we only support empty reference and simple patterns
|
||||
if reference != '' && reference != 'INBOX' {
|
||||
// Just return OK with no results for unsupported references
|
||||
self.conn.write('${tag} OK LIST completed\r\n'.bytes())!
|
||||
return
|
||||
}
|
||||
|
||||
// Handle special case of empty mailbox name
|
||||
if pattern == '' {
|
||||
// Return hierarchy delimiter and root name
|
||||
self.conn.write('* LIST (\\Noselect) "/" ""\r\n'.bytes())!
|
||||
self.conn.write('${tag} OK LIST completed\r\n'.bytes())!
|
||||
return
|
||||
}
|
||||
|
||||
// Get list of mailboxes for the user using mailbox module
|
||||
mailbox_names := self.server.mailboxserver.mailbox_list(self.username) or {
|
||||
self.conn.write('${tag} NO Failed to list mailboxes\r\n'.bytes())!
|
||||
return error('Failed to list mailboxes: ${err}')
|
||||
}
|
||||
|
||||
// Handle % wildcard (single level)
|
||||
if pattern == '%' {
|
||||
// List top-level mailboxes
|
||||
for name in mailbox_names {
|
||||
if !name.contains('/') { // Only top level
|
||||
// Since we don't have direct access to read-only status, use basic attributes
|
||||
mut attrs := []string{}
|
||||
|
||||
// Add child status attributes
|
||||
mut has_children := false
|
||||
for other_name in mailbox_names {
|
||||
if other_name.starts_with(name + '/') {
|
||||
has_children = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if has_children {
|
||||
attrs << '\\HasChildren'
|
||||
} else {
|
||||
attrs << '\\HasNoChildren'
|
||||
}
|
||||
attr_str := if attrs.len > 0 { '(${attrs.join(' ')})' } else { '()' }
|
||||
self.conn.write('* LIST ${attr_str} "/" "${name}"\r\n'.bytes())!
|
||||
}
|
||||
}
|
||||
self.conn.write('${tag} OK LIST completed\r\n'.bytes())!
|
||||
return
|
||||
}
|
||||
|
||||
// Handle * wildcard (multiple levels)
|
||||
if pattern == '*' {
|
||||
// List all mailboxes
|
||||
for name in mailbox_names {
|
||||
// Since we don't have direct access to read-only status, use basic attributes
|
||||
mut attrs := []string{}
|
||||
|
||||
// Add child status attributes
|
||||
mut has_children := false
|
||||
for other_name in mailbox_names {
|
||||
if other_name.starts_with(name + '/') {
|
||||
has_children = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if has_children {
|
||||
attrs << '\\HasChildren'
|
||||
} else {
|
||||
attrs << '\\HasNoChildren'
|
||||
}
|
||||
attr_str := if attrs.len > 0 { '(${attrs.join(' ')})' } else { '()' }
|
||||
self.conn.write('* LIST ${attr_str} "/" "${name}"\r\n'.bytes())!
|
||||
}
|
||||
self.conn.write('${tag} OK LIST completed\r\n'.bytes())!
|
||||
return
|
||||
}
|
||||
|
||||
// Handle exact mailbox name
|
||||
if pattern in mailbox_names {
|
||||
// Since we don't have direct access to read-only status, use basic attributes
|
||||
mut attrs := []string{}
|
||||
|
||||
// Add child status attributes
|
||||
mut has_children := false
|
||||
for other_name in mailbox_names {
|
||||
if other_name.starts_with(pattern + '/') {
|
||||
has_children = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if has_children {
|
||||
attrs << '\\HasChildren'
|
||||
} else {
|
||||
attrs << '\\HasNoChildren'
|
||||
}
|
||||
attr_str := if attrs.len > 0 { '(${attrs.join(' ')})' } else { '()' }
|
||||
self.conn.write('* LIST ${attr_str} "/" "${pattern}"\r\n'.bytes())!
|
||||
}
|
||||
|
||||
self.conn.write('${tag} OK LIST completed\r\n'.bytes())!
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
module imap
|
||||
|
||||
import net
|
||||
|
||||
// handle_login processes the LOGIN command
|
||||
// See RFC 3501 Section 6.2.3
|
||||
pub fn (mut self Session) handle_login(tag string, parts []string) ! {
|
||||
// Check if LOGINDISABLED is advertised
|
||||
if self.capabilities.contains('LOGINDISABLED') {
|
||||
self.conn.write('${tag} NO [PRIVACYREQUIRED] LOGIN disabled\r\n'.bytes())!
|
||||
return
|
||||
}
|
||||
|
||||
if parts.len < 4 {
|
||||
self.conn.write('${tag} BAD LOGIN requires username and password\r\n'.bytes())!
|
||||
return
|
||||
}
|
||||
|
||||
username := parts[2]
|
||||
password := parts[3]
|
||||
|
||||
// For demo purposes, accept any username and look it up in the mailbox server
|
||||
// In a real implementation, we would validate the password here
|
||||
|
||||
// Try to find existing account by email
|
||||
email := '${username}@example.com'
|
||||
existing_username := self.server.mailboxserver.account_find_by_email(email) or {
|
||||
// Create a new account if not found
|
||||
self.server.mailboxserver.account_create(username, username, [
|
||||
'${username}@example.com',
|
||||
]) or {
|
||||
self.conn.write('${tag} NO [AUTHENTICATIONFAILED] Failed to create account\r\n'.bytes())!
|
||||
return
|
||||
}
|
||||
username // Return the new username
|
||||
}
|
||||
|
||||
self.username = existing_username
|
||||
|
||||
// Update capabilities - remove LOGINDISABLED and STARTTLS after login
|
||||
self.capabilities = self.capabilities.filter(it != 'LOGINDISABLED' && it != 'STARTTLS')
|
||||
|
||||
// Send OK response with updated capabilities
|
||||
self.conn.write('${tag} OK [CAPABILITY ${self.capabilities.join(' ')}] LOGIN completed\r\n'.bytes())!
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
module imap
|
||||
|
||||
import net
|
||||
|
||||
// handle_logout processes the LOGOUT command
|
||||
pub fn (mut self Session) handle_logout(tag string) ! {
|
||||
self.conn.write('* BYE IMAP4rev2 Server logging out\r\n'.bytes())!
|
||||
self.conn.write('${tag} OK LOGOUT completed\r\n'.bytes())!
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
module imap
|
||||
|
||||
import net
|
||||
|
||||
// handle_select processes the SELECT command
|
||||
// See RFC 3501 Section 6.3.2
|
||||
pub fn (mut self Session) handle_select(tag string, parts []string) ! {
|
||||
if parts.len < 3 {
|
||||
self.conn.write('${tag} BAD SELECT requires a mailbox name\r\n'.bytes())!
|
||||
return error('SELECT requires a mailbox name')
|
||||
}
|
||||
|
||||
// Check if user is logged in
|
||||
if self.username == '' {
|
||||
self.conn.write('${tag} NO Must be logged in first\r\n'.bytes())!
|
||||
return error('Not logged in')
|
||||
}
|
||||
|
||||
// If there's a currently selected mailbox, send CLOSED response
|
||||
if self.mailbox != '' {
|
||||
self.conn.write('* OK [CLOSED] Previous mailbox is now closed\r\n'.bytes())!
|
||||
}
|
||||
|
||||
// Remove any surrounding quotes from mailbox name
|
||||
mailbox_name := parts[2].trim('"')
|
||||
|
||||
// Check if mailbox exists by trying to list messages
|
||||
messages := self.server.mailboxserver.message_list(self.username, mailbox_name) or {
|
||||
self.conn.write('${tag} NO Mailbox does not exist\r\n'.bytes())!
|
||||
return error('Mailbox does not exist')
|
||||
}
|
||||
|
||||
messages_count := messages.len
|
||||
|
||||
// Required untagged responses per spec:
|
||||
// 1. FLAGS - list of flags that can be set on messages
|
||||
self.conn.write('* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n'.bytes())!
|
||||
|
||||
// 2. EXISTS - number of messages
|
||||
self.conn.write('* ${messages_count} EXISTS\r\n'.bytes())!
|
||||
|
||||
// Required OK untagged responses:
|
||||
// 1. PERMANENTFLAGS
|
||||
self.conn.write('* OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\*)] Flags permitted\r\n'.bytes())!
|
||||
|
||||
// 2. UIDNEXT - Use a high number since we don't have direct access to next_uid
|
||||
self.conn.write('* OK [UIDNEXT 4294967295] Predicted next UID\r\n'.bytes())!
|
||||
|
||||
// 3. UIDVALIDITY - Use a constant since we don't have direct access to uid_validity
|
||||
self.conn.write('* OK [UIDVALIDITY 1] UIDs valid\r\n'.bytes())!
|
||||
|
||||
// Update session's selected mailbox
|
||||
self.mailbox = mailbox_name
|
||||
|
||||
// Always use READ-WRITE mode since we don't have read-only information
|
||||
self.conn.write('${tag} OK [READ-WRITE] SELECT completed\r\n'.bytes())!
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
module imap
|
||||
|
||||
import net
|
||||
import strconv
|
||||
|
||||
// handle_store processes the STORE command
|
||||
// See RFC 3501 Section 6.4.6
|
||||
pub fn (mut self Session) handle_store(tag string, parts []string) ! {
|
||||
// Check if user is logged in
|
||||
if self.username == '' {
|
||||
self.conn.write('${tag} NO Must be logged in first\r\n'.bytes())!
|
||||
return error('Not logged in')
|
||||
}
|
||||
|
||||
// Expecting format like: A003 STORE sequence-set operation flags
|
||||
if parts.len < 5 {
|
||||
self.conn.write('${tag} BAD STORE requires a sequence-set, an operation, and flags\r\n'.bytes())!
|
||||
return
|
||||
}
|
||||
|
||||
// Get all messages to find the target message
|
||||
messages := self.server.mailboxserver.message_list(self.username, self.mailbox)!
|
||||
|
||||
// Parse sequence set (currently only supporting single message numbers)
|
||||
sequence := parts[2]
|
||||
index := strconv.atoi(sequence) or {
|
||||
self.conn.write('${tag} BAD Invalid sequence-set\r\n'.bytes())!
|
||||
return
|
||||
} - 1
|
||||
|
||||
if index < 0 || index >= messages.len {
|
||||
self.conn.write('${tag} NO No such message\r\n'.bytes())!
|
||||
return
|
||||
}
|
||||
|
||||
// Parse operation (FLAGS, +FLAGS, -FLAGS, with optional .SILENT)
|
||||
op := parts[3]
|
||||
silent := op.ends_with('.SILENT')
|
||||
base_op := if silent { op[..op.len - 7] } else { op }
|
||||
|
||||
if base_op !in ['FLAGS', '+FLAGS', '-FLAGS'] {
|
||||
self.conn.write('${tag} BAD Unknown STORE operation\r\n'.bytes())!
|
||||
return
|
||||
}
|
||||
|
||||
// Parse flags
|
||||
flags_str := parts[4]
|
||||
flags_clean := flags_str.trim('()')
|
||||
flags_arr := flags_clean.split(' ').filter(it != '')
|
||||
|
||||
// Validate flags
|
||||
valid_flags := ['\\Answered', '\\Flagged', '\\Deleted', '\\Seen', '\\Draft']
|
||||
for flag in flags_arr {
|
||||
if !flag.starts_with('\\') || flag !in valid_flags {
|
||||
self.conn.write('${tag} BAD Invalid flag\r\n'.bytes())!
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
mut msg := messages[index]
|
||||
old_flags := msg.flags.clone() // Save for comparison
|
||||
|
||||
// Apply flag changes
|
||||
match base_op {
|
||||
'+FLAGS' {
|
||||
// Add each flag if it isn't already present
|
||||
for flag in flags_arr {
|
||||
if flag !in msg.flags {
|
||||
msg.flags << flag
|
||||
}
|
||||
}
|
||||
}
|
||||
'-FLAGS' {
|
||||
// Remove specified flags
|
||||
for flag in flags_arr {
|
||||
msg.flags = msg.flags.filter(it != flag)
|
||||
}
|
||||
}
|
||||
'FLAGS' {
|
||||
// Replace current flags
|
||||
msg.flags = flags_arr
|
||||
}
|
||||
else {}
|
||||
}
|
||||
|
||||
// Save the updated message using mailbox module's message_set
|
||||
self.server.mailboxserver.message_set(self.username, self.mailbox, msg.uid, msg) or {
|
||||
self.conn.write('${tag} NO Failed to update message flags\r\n'.bytes())!
|
||||
return error('Failed to update message flags: ${err}')
|
||||
}
|
||||
|
||||
// Send untagged FETCH response if flags changed and not silent
|
||||
if !silent && msg.flags != old_flags {
|
||||
self.conn.write('* ${index + 1} FETCH (FLAGS (${msg.flags.join(' ')}))\r\n'.bytes())!
|
||||
}
|
||||
|
||||
self.conn.write('${tag} OK STORE completed\r\n'.bytes())!
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
module imap
|
||||
|
||||
import net
|
||||
|
||||
// handle_uid processes the UID command
|
||||
// See RFC 3501 Section 6.4.9
|
||||
pub fn (mut self Session) handle_uid(tag string, parts []string) ! {
|
||||
if parts.len < 3 {
|
||||
self.conn.write('${tag} BAD UID requires a command\r\n'.bytes())!
|
||||
return
|
||||
}
|
||||
|
||||
subcmd := parts[2].to_upper()
|
||||
match subcmd {
|
||||
'FETCH' {
|
||||
// Remove 'UID' from parts and pass to handle_fetch
|
||||
// The handle_fetch implementation already includes UIDs in responses
|
||||
mut fetch_parts := parts.clone()
|
||||
fetch_parts.delete(1) // Remove 'UID'
|
||||
self.handle_fetch(tag, fetch_parts)!
|
||||
}
|
||||
'SEARCH' {
|
||||
// Remove 'UID' from parts and pass to handle_search
|
||||
mut search_parts := parts.clone()
|
||||
search_parts.delete(1) // Remove 'UID'
|
||||
// TODO: Implement handle_search
|
||||
self.conn.write('${tag} NO SEARCH not implemented\r\n'.bytes())!
|
||||
}
|
||||
'STORE' {
|
||||
// Remove 'UID' from parts and pass to handle_store
|
||||
mut store_parts := parts.clone()
|
||||
store_parts.delete(1) // Remove 'UID'
|
||||
self.handle_store(tag, store_parts)!
|
||||
}
|
||||
'COPY' {
|
||||
// Remove 'UID' from parts and pass to handle_copy
|
||||
mut copy_parts := parts.clone()
|
||||
copy_parts.delete(1) // Remove 'UID'
|
||||
// TODO: Implement handle_copy
|
||||
self.conn.write('${tag} NO COPY not implemented\r\n'.bytes())!
|
||||
}
|
||||
'EXPUNGE' {
|
||||
// Remove 'UID' from parts and pass to handle_expunge
|
||||
mut expunge_parts := parts.clone()
|
||||
expunge_parts.delete(1) // Remove 'UID'
|
||||
// TODO: Implement handle_expunge
|
||||
self.conn.write('${tag} NO EXPUNGE not implemented\r\n'.bytes())!
|
||||
}
|
||||
else {
|
||||
self.conn.write('${tag} BAD Unknown UID command\r\n'.bytes())!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
module imap
|
||||
|
||||
import time
|
||||
import freeflowuniverse.herolib.servers.mail.mailbox
|
||||
|
||||
pub fn new(mailboxserver &mailbox.MailServer) !IMAPServer {
|
||||
mut server := IMAPServer{
|
||||
mailboxserver: mailboxserver
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
module imap
|
||||
|
||||
import net
|
||||
import io
|
||||
import freeflowuniverse.herolib.servers.mail.mailbox
|
||||
|
||||
// IMAPServer wraps the mailbox server to provide IMAP functionality
|
||||
@[heap]
|
||||
pub struct IMAPServer {
|
||||
pub mut:
|
||||
mailboxserver &mailbox.MailServer
|
||||
}
|
||||
|
||||
// Session represents an active IMAP client connection
|
||||
pub struct Session {
|
||||
pub mut:
|
||||
server &IMAPServer
|
||||
username string // Currently logged in user
|
||||
mailbox string // Currently selected mailbox name
|
||||
conn net.TcpConn
|
||||
reader &io.BufferedReader
|
||||
tls_active bool // Whether TLS is active on the connection
|
||||
capabilities []string // Current capabilities for this session
|
||||
}
|
||||
|
||||
// mailbox_new creates a new mailbox for the current user
|
||||
pub fn (mut self Session) mailbox_new(name string) ! {
|
||||
if self.username == '' {
|
||||
return error('No user logged in')
|
||||
}
|
||||
self.server.mailboxserver.mailbox_create(self.username, name)!
|
||||
}
|
||||
|
||||
// mailbox_exists checks if the currently selected mailbox exists
|
||||
pub fn (mut self Session) mailbox_exists() bool {
|
||||
if self.username == '' || self.mailbox == '' {
|
||||
return false
|
||||
}
|
||||
mailboxes := self.server.mailboxserver.mailbox_list(self.username) or { return false }
|
||||
return self.mailbox in mailboxes
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
module imap
|
||||
|
||||
import net
|
||||
import strings
|
||||
import freeflowuniverse.herolib.ui.console
|
||||
import time
|
||||
import io
|
||||
|
||||
// Run starts the server on port 143 and accepts client connections.
|
||||
pub fn (mut server IMAPServer) start() ! {
|
||||
spawn daemon(mut server)
|
||||
}
|
||||
|
||||
fn daemon(mut server IMAPServer) ! {
|
||||
addr := '0.0.0.0:143'
|
||||
mut listener := net.listen_tcp(.ip, addr, dualstack: true) or {
|
||||
return error('Failed to listen on ${addr}: ${err}')
|
||||
}
|
||||
println('IMAP Server listening on ${addr}')
|
||||
|
||||
// Set TCP options for better reliability
|
||||
// listener.set_option_bool(.reuse_addr, true)
|
||||
|
||||
for {
|
||||
mut conn := listener.accept() or {
|
||||
eprintln('Failed to accept connection: ${err}')
|
||||
continue
|
||||
}
|
||||
|
||||
// Set connection options
|
||||
|
||||
// conn.set_option_int(.tcp_keepalive, 60)!
|
||||
conn.set_read_timeout(30 * time.second)
|
||||
conn.set_write_timeout(30 * time.second)
|
||||
|
||||
// Handle each connection concurrently
|
||||
spawn handle_connection(mut conn, mut server)
|
||||
}
|
||||
}
|
||||
|
||||
// handle_connection processes commands from a connected client.
|
||||
fn handle_connection(mut conn net.TcpConn, mut server IMAPServer) ! {
|
||||
// Send greeting per IMAP protocol.
|
||||
defer {
|
||||
conn.close() or { panic(err) }
|
||||
}
|
||||
conn.write('* OK [CAPABILITY IMAP4rev2 STARTTLS LOGINDISABLED AUTH=PLAIN] IMAP server ready\r\n'.bytes())!
|
||||
// Initially no mailbox is selected.
|
||||
mut selected_mailbox_name := ''
|
||||
mut res := false
|
||||
client_addr := conn.peer_addr()!
|
||||
console.print_debug('> new client: ${client_addr}')
|
||||
mut reader := io.new_buffered_reader(reader: conn)
|
||||
defer {
|
||||
unsafe {
|
||||
reader.free()
|
||||
}
|
||||
}
|
||||
|
||||
mut session := Session{
|
||||
server: &server
|
||||
mailbox: ''
|
||||
conn: conn
|
||||
reader: reader
|
||||
tls_active: false
|
||||
capabilities: ['IMAP4rev2', 'STARTTLS', 'LOGINDISABLED', 'AUTH=PLAIN']
|
||||
}
|
||||
|
||||
for {
|
||||
// Read a line (command) from the client.
|
||||
line := reader.read_line() or {
|
||||
match err.msg() {
|
||||
'closed' {
|
||||
console.print_debug('Client disconnected normally')
|
||||
return error('client disconnected')
|
||||
}
|
||||
'EOF' {
|
||||
console.print_debug('Client connection ended (EOF)')
|
||||
return error('connection ended')
|
||||
}
|
||||
else {
|
||||
eprintln('Connection read error: ${err}')
|
||||
return error('connection error: ${err}')
|
||||
}
|
||||
}
|
||||
}
|
||||
console.print_debug(line)
|
||||
trimmed := line.trim_space()
|
||||
if trimmed.len == 0 {
|
||||
continue
|
||||
}
|
||||
// Commands come with a tag followed by the command and parameters.
|
||||
parts := trimmed.split(' ')
|
||||
if parts.len < 2 {
|
||||
conn.write('${parts[0]} BAD Invalid command\r\n'.bytes())!
|
||||
continue
|
||||
}
|
||||
tag := parts[0]
|
||||
cmd := parts[1].to_upper()
|
||||
match cmd {
|
||||
'LOGIN' {
|
||||
session.handle_login(tag, parts)!
|
||||
}
|
||||
'AUTHENTICATE' {
|
||||
session.handle_authenticate(tag, parts)!
|
||||
}
|
||||
'SELECT' {
|
||||
session.handle_select(tag, parts)!
|
||||
}
|
||||
'FETCH' {
|
||||
session.handle_fetch(tag, parts)!
|
||||
}
|
||||
'STORE' {
|
||||
session.handle_store(tag, parts)!
|
||||
}
|
||||
'CAPABILITY' {
|
||||
session.handle_capability(tag)!
|
||||
}
|
||||
'LIST' {
|
||||
session.handle_list(tag, parts)!
|
||||
}
|
||||
'UID' {
|
||||
session.handle_uid(tag, parts)!
|
||||
}
|
||||
'CLOSE' {
|
||||
session.handle_close(tag)!
|
||||
}
|
||||
'LOGOUT' {
|
||||
session.handle_logout(tag)!
|
||||
return
|
||||
}
|
||||
else {
|
||||
conn.write('${tag} BAD Unknown command\r\n'.bytes())!
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,82 +0,0 @@
|
||||
# Mailbox Module
|
||||
|
||||
A V language implementation of a mailbox system that provides core functionality for managing email messages. This module is designed to be used as part of an email server implementation, providing the fundamental storage and retrieval operations for email messages.
|
||||
|
||||
## Core Components
|
||||
|
||||
### Message
|
||||
|
||||
```v
|
||||
pub struct Message {
|
||||
pub mut:
|
||||
uid u32 // Unique identifier for the message
|
||||
subject string
|
||||
body string
|
||||
flags []string // e.g.: ["\Seen", "\Flagged"]
|
||||
internal_date time.Time // Message arrival time
|
||||
}
|
||||
```
|
||||
|
||||
### MailServer
|
||||
|
||||
The MailServer struct provides the following public methods:
|
||||
|
||||
#### Account Management
|
||||
- `account_create(username string, description string, emails []string) !` - Creates a new user account
|
||||
- `account_delete(username string) !` - Deletes a user account
|
||||
- `account_list() []string` - Lists all usernames
|
||||
- `account_find_by_email(email string) !string` - Finds account by email address
|
||||
|
||||
#### Mailbox Management
|
||||
- `mailbox_list(username string) ![]string` - Lists all mailboxes for a user
|
||||
- `mailbox_create(username string, mailbox string) !` - Creates a new mailbox for a user
|
||||
- `mailbox_delete(username string, mailbox string) !` - Deletes a mailbox for a user
|
||||
|
||||
#### Message Management
|
||||
- `message_list(username string, mailbox string) ![]Message` - Returns all messages in the mailbox
|
||||
- `message_get(username string, mailbox string, uid u32) !Message` - Gets a message by its UID
|
||||
- `message_delete(username string, mailbox string, uid u32) !` - Deletes a message by its UID
|
||||
- `message_set(username string, mailbox string, uid u32, msg Message) !` - Sets/updates a message with the given UID
|
||||
- `message_find(username string, mailbox string, args FindArgs) ![]Message` - Finds messages matching the given criteria
|
||||
- `message_len(username string, mailbox string) !int` - Returns the number of messages in a mailbox
|
||||
|
||||
## Usage Example
|
||||
|
||||
```v
|
||||
// Create a new mail server
|
||||
mut server := new()
|
||||
|
||||
// Create a user account
|
||||
server.account_create('user1', 'First User', ['user1@example.com'])!
|
||||
|
||||
// Create a new mailbox
|
||||
server.mailbox_create('user1', 'Important')!
|
||||
|
||||
// Add a message
|
||||
msg := Message{
|
||||
uid: 1
|
||||
subject: 'Hello'
|
||||
body: 'World'
|
||||
flags: ['\\Seen']
|
||||
}
|
||||
server.message_set('user1', 'Important', msg.uid, msg)!
|
||||
|
||||
// List messages
|
||||
messages := server.message_list('user1', 'Important')!
|
||||
|
||||
// Find messages
|
||||
results := server.message_find('user1', 'Important', FindArgs{
|
||||
subject: 'Hello'
|
||||
content: 'World'
|
||||
flags: ['\\Seen']
|
||||
})!
|
||||
|
||||
// Delete a message
|
||||
server.message_delete('user1', 'Important', 1)!
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Each message has a unique identifier (UID) that remains constant
|
||||
- Messages can be flagged with standard IMAP flags (e.g. \\Seen, \\Flagged)
|
||||
- Search operations support filtering by subject, content, and flags
|
||||
@@ -1,45 +0,0 @@
|
||||
module mailbox
|
||||
|
||||
import time
|
||||
|
||||
// Creates demo data with 5 user accounts, each having 2 mailboxes and 20 messages
|
||||
pub fn (mut self MailServer) demodata() ! {
|
||||
usernames := ['user1', 'user2', 'user3', 'user4', 'user5']
|
||||
names := ['First User', 'Second User', 'Third User', 'Fourth User', 'Fifth User']
|
||||
|
||||
for i, username in usernames {
|
||||
// Create primary and alternate email addresses
|
||||
primary_email := '${username}@example.com'
|
||||
alt_email := '${username}.alt@example.com'
|
||||
emails := [primary_email, alt_email]
|
||||
|
||||
// Create user account (INBOX is created by default)
|
||||
self.account_create(username, names[i], emails)!
|
||||
|
||||
// Create second mailbox
|
||||
self.mailbox_create(username, 'Sent')!
|
||||
|
||||
// Add 10 messages to each mailbox
|
||||
for j in 0 .. 10 {
|
||||
// Add message to INBOX
|
||||
inbox_msg := Message{
|
||||
uid: u32(j + 1) // UIDs start at 1
|
||||
subject: 'Inbox Message ${j + 1}'
|
||||
body: 'This is inbox message ${j + 1} for ${username}'
|
||||
flags: if j % 2 == 0 { ['\\Seen'] } else { [] }
|
||||
internal_date: time.now()
|
||||
}
|
||||
self.message_set(username, 'INBOX', inbox_msg.uid, inbox_msg)!
|
||||
|
||||
// Add message to Sent
|
||||
sent_msg := Message{
|
||||
uid: u32(j + 1) // UIDs start at 1
|
||||
subject: 'Sent Message ${j + 1}'
|
||||
body: 'This is sent message ${j + 1} from ${username}'
|
||||
flags: ['\\Seen']
|
||||
internal_date: time.now()
|
||||
}
|
||||
self.message_set(username, 'Sent', sent_msg.uid, sent_msg)!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
module mailbox
|
||||
|
||||
fn test_demodata() {
|
||||
mut server := new()
|
||||
server.demodata()!
|
||||
|
||||
// Test user accounts
|
||||
usernames := ['user1', 'user2', 'user3', 'user4', 'user5']
|
||||
names := ['First User', 'Second User', 'Third User', 'Fourth User', 'Fifth User']
|
||||
|
||||
for i, username in usernames {
|
||||
// Verify user exists by checking email
|
||||
primary_email := '${username}@example.com'
|
||||
found_username := server.account_find_by_email(primary_email) or { panic(err) }
|
||||
assert found_username == username
|
||||
|
||||
alt_email := '${username}.alt@example.com'
|
||||
found_username_alt := server.account_find_by_email(alt_email) or { panic(err) }
|
||||
assert found_username_alt == username
|
||||
|
||||
// Verify mailboxes exist
|
||||
mailboxes := server.mailbox_list(username) or { panic(err) }
|
||||
assert mailboxes.len == 2
|
||||
assert 'INBOX' in mailboxes
|
||||
assert 'Sent' in mailboxes
|
||||
|
||||
// Verify INBOX messages
|
||||
inbox_messages := server.message_list(username, 'INBOX') or { panic(err) }
|
||||
assert inbox_messages.len == 10
|
||||
|
||||
// Check specific properties of first and last INBOX messages
|
||||
first_msg := server.message_get(username, 'INBOX', u32(1)) or { panic(err) }
|
||||
assert first_msg.subject == 'Inbox Message 1'
|
||||
assert first_msg.body == 'This is inbox message 1 for ${username}'
|
||||
assert first_msg.flags == ['\\Seen']
|
||||
|
||||
last_msg := server.message_get(username, 'INBOX', u32(10)) or { panic(err) }
|
||||
assert last_msg.subject == 'Inbox Message 10'
|
||||
assert last_msg.body == 'This is inbox message 10 for ${username}'
|
||||
assert last_msg.flags == if 9 % 2 == 0 {
|
||||
['\\Seen']
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
|
||||
// Verify Sent messages
|
||||
sent_messages := server.message_list(username, 'Sent') or { panic(err) }
|
||||
assert sent_messages.len == 10
|
||||
|
||||
// Check specific properties of first and last Sent messages
|
||||
first_sent := server.message_get(username, 'Sent', u32(1)) or { panic(err) }
|
||||
assert first_sent.subject == 'Sent Message 1'
|
||||
assert first_sent.body == 'This is sent message 1 from ${username}'
|
||||
assert first_sent.flags == ['\\Seen']
|
||||
|
||||
last_sent := server.message_get(username, 'Sent', u32(10)) or { panic(err) }
|
||||
assert last_sent.subject == 'Sent Message 10'
|
||||
assert last_sent.body == 'This is sent message 10 from ${username}'
|
||||
assert last_sent.flags == ['\\Seen']
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
module mailbox
|
||||
|
||||
pub fn new() &MailServer {
|
||||
return &MailServer{
|
||||
accounts: map[string]&UserAccount{}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_demo_data() !&MailServer {
|
||||
mut s := &MailServer{
|
||||
accounts: map[string]&UserAccount{}
|
||||
}
|
||||
s.demodata()!
|
||||
return s
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
module mailbox
|
||||
|
||||
// Represents a mailbox holding messages.
|
||||
@[heap]
|
||||
struct Mailbox {
|
||||
mut:
|
||||
name string
|
||||
messages []Message
|
||||
next_uid u32 // Next unique identifier to be assigned
|
||||
uid_validity u32 // Unique identifier validity value
|
||||
read_only bool // Whether mailbox is read-only
|
||||
}
|
||||
|
||||
// Returns all messages in the mailbox
|
||||
fn (mut self Mailbox) list() ![]Message {
|
||||
return self.messages
|
||||
}
|
||||
|
||||
// Gets a message by its UID
|
||||
fn (mut self Mailbox) get(uid u32) !Message {
|
||||
for msg in self.messages {
|
||||
if msg.uid == uid {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
return error('Message with UID ${uid} not found')
|
||||
}
|
||||
|
||||
// Deletes a message by its UID
|
||||
fn (mut self Mailbox) delete(uid u32) ! {
|
||||
for i, msg in self.messages {
|
||||
if msg.uid == uid {
|
||||
self.messages.delete(i)
|
||||
return
|
||||
}
|
||||
}
|
||||
return error('Message with UID ${uid} not found')
|
||||
}
|
||||
|
||||
// Sets/updates a message with the given UID
|
||||
fn (mut self Mailbox) set(uid u32, msg Message) ! {
|
||||
if self.read_only {
|
||||
return error('Mailbox is read-only')
|
||||
}
|
||||
|
||||
mut found := false
|
||||
for i, existing in self.messages {
|
||||
if existing.uid == uid {
|
||||
self.messages[i] = msg
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
// Add as new message if UID doesn't exist
|
||||
self.messages << msg
|
||||
}
|
||||
}
|
||||
|
||||
@[params]
|
||||
pub struct FindArgs {
|
||||
pub mut:
|
||||
subject string
|
||||
content string
|
||||
flags []string
|
||||
}
|
||||
|
||||
// Finds messages matching the given criteria
|
||||
fn (mut self Mailbox) find(args FindArgs) ![]Message {
|
||||
mut results := []Message{}
|
||||
|
||||
for msg in self.messages {
|
||||
mut matches := true
|
||||
|
||||
// Check subject if specified
|
||||
if args.subject != '' && !msg.subject.contains(args.subject) {
|
||||
matches = false
|
||||
}
|
||||
|
||||
// Check content if specified
|
||||
if matches && args.content != '' && !msg.body.contains(args.content) {
|
||||
matches = false
|
||||
}
|
||||
|
||||
// Check all specified flags are present
|
||||
if matches && args.flags.len > 0 {
|
||||
for flag in args.flags {
|
||||
if flag !in msg.flags {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matches {
|
||||
results << msg
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
fn (mut self Mailbox) len() int {
|
||||
return self.messages.len
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
module mailbox
|
||||
|
||||
import time
|
||||
|
||||
fn test_mailbox_basic_operations() {
|
||||
mut mb := Mailbox{
|
||||
name: 'INBOX'
|
||||
uid_validity: 1234
|
||||
}
|
||||
|
||||
// Test empty mailbox
|
||||
msgs := mb.list() or { panic(err) }
|
||||
assert msgs.len == 0
|
||||
|
||||
// Test adding a message
|
||||
msg1 := Message{
|
||||
uid: 1
|
||||
subject: 'Test email'
|
||||
body: 'Hello world'
|
||||
flags: ['\\Seen']
|
||||
internal_date: time.now()
|
||||
}
|
||||
mb.set(1, msg1) or { panic(err) }
|
||||
|
||||
// Test listing messages
|
||||
msgs2 := mb.list() or { panic(err) }
|
||||
assert msgs2.len == 1
|
||||
assert msgs2[0].subject == 'Test email'
|
||||
|
||||
// Test getting message by UID
|
||||
found := mb.get(1) or { panic(err) }
|
||||
assert found.uid == 1
|
||||
assert found.subject == 'Test email'
|
||||
assert found.body == 'Hello world'
|
||||
assert found.flags == ['\\Seen']
|
||||
}
|
||||
|
||||
fn test_mailbox_delete() {
|
||||
mut mb := Mailbox{
|
||||
name: 'INBOX'
|
||||
uid_validity: 1234
|
||||
}
|
||||
|
||||
// Add two messages
|
||||
msg1 := Message{
|
||||
uid: 1
|
||||
subject: 'First email'
|
||||
body: 'Content 1'
|
||||
}
|
||||
msg2 := Message{
|
||||
uid: 2
|
||||
subject: 'Second email'
|
||||
body: 'Content 2'
|
||||
}
|
||||
mb.set(1, msg1) or { panic(err) }
|
||||
mb.set(2, msg2) or { panic(err) }
|
||||
|
||||
// Delete first message
|
||||
mb.delete(1) or { panic(err) }
|
||||
|
||||
// Verify only second message remains
|
||||
msgs := mb.list() or { panic(err) }
|
||||
assert msgs.len == 1
|
||||
assert msgs[0].uid == 2
|
||||
assert msgs[0].subject == 'Second email'
|
||||
|
||||
// Test deleting non-existent message
|
||||
if _ := mb.delete(999) {
|
||||
panic('Expected error when deleting non-existent message')
|
||||
}
|
||||
}
|
||||
|
||||
fn test_mailbox_find() {
|
||||
mut mb := Mailbox{
|
||||
name: 'INBOX'
|
||||
uid_validity: 1234
|
||||
}
|
||||
|
||||
// Add test messages
|
||||
msg1 := Message{
|
||||
uid: 1
|
||||
subject: 'Important meeting'
|
||||
body: 'Meeting at 2 PM'
|
||||
flags: ['\\Seen', '\\Flagged']
|
||||
}
|
||||
msg2 := Message{
|
||||
uid: 2
|
||||
subject: 'Hello friend'
|
||||
body: 'How are you?'
|
||||
flags: ['\\Seen']
|
||||
}
|
||||
msg3 := Message{
|
||||
uid: 3
|
||||
subject: 'Another meeting'
|
||||
body: 'Team sync at 3 PM'
|
||||
flags: ['\\Draft']
|
||||
}
|
||||
|
||||
mb.set(1, msg1) or { panic(err) }
|
||||
mb.set(2, msg2) or { panic(err) }
|
||||
mb.set(3, msg3) or { panic(err) }
|
||||
|
||||
// Test finding by subject
|
||||
found_subject := mb.find(FindArgs{ subject: 'meeting' }) or { panic(err) }
|
||||
assert found_subject.len == 2
|
||||
|
||||
// Test finding by content
|
||||
found_content := mb.find(FindArgs{ content: 'PM' }) or { panic(err) }
|
||||
assert found_content.len == 2
|
||||
|
||||
// Test finding by flags
|
||||
found_flags := mb.find(FindArgs{ flags: ['\\Seen', '\\Flagged'] }) or { panic(err) }
|
||||
assert found_flags.len == 1
|
||||
assert found_flags[0].uid == 1
|
||||
|
||||
// Test finding with multiple criteria
|
||||
found_multi := mb.find(FindArgs{
|
||||
subject: 'meeting'
|
||||
flags: ['\\Draft']
|
||||
}) or { panic(err) }
|
||||
assert found_multi.len == 1
|
||||
assert found_multi[0].uid == 3
|
||||
}
|
||||
|
||||
fn test_readonly_mailbox() {
|
||||
mut mb := Mailbox{
|
||||
name: 'INBOX'
|
||||
uid_validity: 1234
|
||||
read_only: true
|
||||
}
|
||||
|
||||
msg := Message{
|
||||
uid: 1
|
||||
subject: 'Test email'
|
||||
body: 'Hello world'
|
||||
}
|
||||
|
||||
// Attempt to modify read-only mailbox should fail
|
||||
if _ := mb.set(1, msg) {
|
||||
panic('Expected error when modifying read-only mailbox')
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
module mailbox
|
||||
|
||||
// Represents the mail server that manages user accounts
|
||||
@[heap]
|
||||
pub struct MailServer {
|
||||
mut:
|
||||
accounts map[string]&UserAccount // Map of username to user account
|
||||
}
|
||||
|
||||
// Creates a new user account
|
||||
pub fn (mut self MailServer) account_create(username string, description string, emails []string) ! {
|
||||
if username in self.accounts {
|
||||
return error('User ${username} already exists')
|
||||
}
|
||||
|
||||
// Verify emails are unique across all accounts
|
||||
for _, account in self.accounts {
|
||||
for email in emails {
|
||||
if email in account.emails {
|
||||
return error('Email ${email} is already registered to another account')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mut account := &UserAccount{
|
||||
name: username
|
||||
description: description
|
||||
emails: emails.clone()
|
||||
mailboxes: map[string]&Mailbox{}
|
||||
}
|
||||
self.accounts[username] = account
|
||||
|
||||
// Create default INBOX mailbox
|
||||
account.create_mailbox('INBOX') or { return err }
|
||||
}
|
||||
|
||||
// Deletes a user account
|
||||
pub fn (mut self MailServer) account_delete(username string) ! {
|
||||
if username !in self.accounts {
|
||||
return error('User ${username} not found')
|
||||
}
|
||||
self.accounts.delete(username)
|
||||
}
|
||||
|
||||
// Lists all usernames
|
||||
pub fn (self MailServer) account_list() []string {
|
||||
return self.accounts.keys()
|
||||
}
|
||||
|
||||
// Finds account by email address
|
||||
pub fn (mut self MailServer) account_find_by_email(email string) !string {
|
||||
for _, account in self.accounts {
|
||||
if email in account.emails {
|
||||
return account.name
|
||||
}
|
||||
}
|
||||
return error('No account found with email ${email}')
|
||||
}
|
||||
|
||||
// Gets a user account by username (internal only)
|
||||
fn (mut self MailServer) account_get(username string) !&UserAccount {
|
||||
if account := self.accounts[username] {
|
||||
return account
|
||||
}
|
||||
return error('User ${username} not found')
|
||||
}
|
||||
|
||||
// Lists all mailboxes for a user
|
||||
pub fn (mut self MailServer) mailbox_list(username string) ![]string {
|
||||
account := self.account_get(username)!
|
||||
return account.list_mailboxes()
|
||||
}
|
||||
|
||||
// Creates a new mailbox for a user
|
||||
pub fn (mut self MailServer) mailbox_create(username string, mailbox string) ! {
|
||||
mut account := self.account_get(username)!
|
||||
account.create_mailbox(mailbox)!
|
||||
}
|
||||
|
||||
// Deletes a mailbox for a user
|
||||
pub fn (mut self MailServer) mailbox_delete(username string, mailbox string) ! {
|
||||
mut account := self.account_get(username)!
|
||||
account.delete_mailbox(mailbox)!
|
||||
}
|
||||
|
||||
// Returns all messages in the mailbox
|
||||
pub fn (mut self MailServer) message_list(username string, mailbox string) ![]Message {
|
||||
mut account := self.account_get(username)!
|
||||
mut mb := account.get_mailbox(mailbox)!
|
||||
return mb.list()
|
||||
}
|
||||
|
||||
// Gets a message by its UID
|
||||
pub fn (mut self MailServer) message_get(username string, mailbox string, uid u32) !Message {
|
||||
mut account := self.account_get(username)!
|
||||
mut mb := account.get_mailbox(mailbox)!
|
||||
return mb.get(uid)
|
||||
}
|
||||
|
||||
// Deletes a message by its UID
|
||||
pub fn (mut self MailServer) message_delete(username string, mailbox string, uid u32) ! {
|
||||
mut account := self.account_get(username)!
|
||||
mut mb := account.get_mailbox(mailbox)!
|
||||
mb.delete(uid)!
|
||||
}
|
||||
|
||||
// Sets/updates a message with the given UID
|
||||
pub fn (mut self MailServer) message_set(username string, mailbox string, uid u32, msg Message) ! {
|
||||
mut account := self.account_get(username)!
|
||||
mut mb := account.get_mailbox(mailbox)!
|
||||
mb.set(uid, msg)!
|
||||
}
|
||||
|
||||
// Finds messages matching the given criteria
|
||||
pub fn (mut self MailServer) message_find(username string, mailbox string, args FindArgs) ![]Message {
|
||||
mut account := self.account_get(username)!
|
||||
mut mb := account.get_mailbox(mailbox)!
|
||||
return mb.find(args)
|
||||
}
|
||||
|
||||
// Returns the number of messages in a mailbox
|
||||
pub fn (mut self MailServer) message_len(username string, mailbox string) !int {
|
||||
mut account := self.account_get(username)!
|
||||
mut mb := account.get_mailbox(mailbox)!
|
||||
return mb.len()
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
module mailbox
|
||||
|
||||
import time
|
||||
|
||||
// Represents an email message.
|
||||
@[heap]
|
||||
pub struct Message {
|
||||
pub mut:
|
||||
uid u32 // Unique identifier for the message
|
||||
subject string
|
||||
body string
|
||||
flags []string // e.g.: ["\\Seen", "\\Flagged"]
|
||||
internal_date time.Time // Message arrival time
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
module mailbox
|
||||
|
||||
import time
|
||||
|
||||
// Represents a user account in the mail server
|
||||
@[heap]
|
||||
struct UserAccount {
|
||||
mut:
|
||||
name string
|
||||
description string
|
||||
emails []string
|
||||
mailboxes map[string]&Mailbox // Map of mailbox name to mailbox instance
|
||||
}
|
||||
|
||||
// Creates a new mailbox for the user account
|
||||
fn (mut self UserAccount) create_mailbox(name string) !&Mailbox {
|
||||
if name in self.mailboxes {
|
||||
return error('Mailbox ${name} already exists')
|
||||
}
|
||||
|
||||
mb := &Mailbox{
|
||||
name: name
|
||||
uid_validity: u32(time.now().unix())
|
||||
}
|
||||
self.mailboxes[name] = mb
|
||||
return mb
|
||||
}
|
||||
|
||||
// Gets a mailbox by name
|
||||
fn (mut self UserAccount) get_mailbox(name string) !&Mailbox {
|
||||
if mailbox := self.mailboxes[name] {
|
||||
return mailbox
|
||||
}
|
||||
return error('Mailbox ${name} not found')
|
||||
}
|
||||
|
||||
// Deletes a mailbox by name
|
||||
fn (mut self UserAccount) delete_mailbox(name string) ! {
|
||||
if name !in self.mailboxes {
|
||||
return error('Mailbox ${name} not found')
|
||||
}
|
||||
self.mailboxes.delete(name)
|
||||
}
|
||||
|
||||
// Lists all mailboxes for the user
|
||||
fn (self UserAccount) list_mailboxes() []string {
|
||||
return self.mailboxes.keys()
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
module mailbox
|
||||
|
||||
import time
|
||||
|
||||
fn test_user_account_mailboxes() {
|
||||
mut account := UserAccount{
|
||||
name: 'testuser'
|
||||
description: 'Test User'
|
||||
emails: ['test@example.com']
|
||||
}
|
||||
|
||||
// Test creating mailboxes
|
||||
inbox := account.create_mailbox('INBOX') or { panic(err) }
|
||||
assert inbox.name == 'INBOX'
|
||||
|
||||
sent := account.create_mailbox('Sent') or { panic(err) }
|
||||
assert sent.name == 'Sent'
|
||||
|
||||
// Test duplicate mailbox creation
|
||||
if _ := account.create_mailbox('INBOX') {
|
||||
panic('Expected error when creating duplicate mailbox')
|
||||
}
|
||||
|
||||
// Test listing mailboxes
|
||||
boxes := account.list_mailboxes()
|
||||
assert boxes.len == 2
|
||||
assert 'INBOX' in boxes
|
||||
assert 'Sent' in boxes
|
||||
|
||||
// Test getting mailbox
|
||||
found := account.get_mailbox('INBOX') or { panic(err) }
|
||||
assert found.name == 'INBOX'
|
||||
|
||||
// Test getting non-existent mailbox
|
||||
if _ := account.get_mailbox('NonExistent') {
|
||||
panic('Expected error when getting non-existent mailbox')
|
||||
}
|
||||
|
||||
// Test deleting mailbox
|
||||
account.delete_mailbox('Sent') or { panic(err) }
|
||||
boxes_after_delete := account.list_mailboxes()
|
||||
assert boxes_after_delete.len == 1
|
||||
assert 'Sent' !in boxes_after_delete
|
||||
}
|
||||
|
||||
fn test_mail_server_accounts() {
|
||||
mut server := MailServer{}
|
||||
|
||||
// Test creating accounts
|
||||
server.account_create('user1', 'First User', ['user1@example.com', 'user1.alt@example.com']) or {
|
||||
panic(err)
|
||||
}
|
||||
mut account1 := server.account_get('user1') or { panic(err) }
|
||||
assert account1.name == 'user1'
|
||||
assert account1.emails.len == 2
|
||||
|
||||
// Verify INBOX was created automatically
|
||||
mut inbox := account1.get_mailbox('INBOX') or { panic(err) }
|
||||
assert inbox.name == 'INBOX'
|
||||
|
||||
// Test creating account with duplicate username
|
||||
if _ := server.account_create('user1', 'Duplicate User', ['other@example.com']) {
|
||||
panic('Expected error when creating account with duplicate username')
|
||||
}
|
||||
|
||||
// Test creating account with duplicate email
|
||||
if _ := server.account_create('user2', 'Second User', ['user1@example.com']) {
|
||||
panic('Expected error when creating account with duplicate email')
|
||||
}
|
||||
|
||||
// Test creating another valid account
|
||||
server.account_create('user2', 'Second User', ['user2@example.com']) or { panic(err) }
|
||||
mut account2 := server.account_get('user2') or { panic(err) }
|
||||
assert account2.name == 'user2'
|
||||
|
||||
// Test listing accounts
|
||||
accounts := server.account_list()
|
||||
assert accounts.len == 2
|
||||
assert 'user1' in accounts
|
||||
assert 'user2' in accounts
|
||||
|
||||
// Test getting account
|
||||
mut found := server.account_get('user1') or { panic(err) }
|
||||
assert found.name == 'user1'
|
||||
assert found.emails == ['user1@example.com', 'user1.alt@example.com']
|
||||
|
||||
// Test getting non-existent account
|
||||
if _ := server.account_get('nonexistent') {
|
||||
panic('Expected error when getting non-existent account')
|
||||
}
|
||||
|
||||
// Test finding account by email
|
||||
found_by_email := server.account_find_by_email('user1.alt@example.com') or { panic(err) }
|
||||
assert found_by_email == 'user1'
|
||||
|
||||
// Test finding non-existent email
|
||||
if _ := server.account_find_by_email('nonexistent@example.com') {
|
||||
panic('Expected error when finding non-existent email')
|
||||
}
|
||||
|
||||
// Test deleting account
|
||||
server.account_delete('user2') or { panic(err) }
|
||||
accounts_after_delete := server.account_list()
|
||||
assert accounts_after_delete.len == 1
|
||||
assert 'user2' !in accounts_after_delete
|
||||
}
|
||||
|
||||
fn test_end_to_end() {
|
||||
mut server := MailServer{}
|
||||
|
||||
// Create account
|
||||
server.account_create('testuser', 'Test User', ['test@example.com']) or { panic(err) }
|
||||
mut account := server.account_get('testuser') or { panic(err) }
|
||||
|
||||
// Get INBOX and add a message
|
||||
mut inbox := account.get_mailbox('INBOX') or { panic(err) }
|
||||
msg := Message{
|
||||
uid: 1
|
||||
subject: 'Test message'
|
||||
body: 'Hello world'
|
||||
flags: ['\\Seen']
|
||||
}
|
||||
inbox.set(1, msg) or { panic(err) }
|
||||
|
||||
// Create Archives mailbox
|
||||
mut archives := account.create_mailbox('Archives') or { panic(err) }
|
||||
|
||||
// Verify mailboxes through server lookup
|
||||
mut found_account := server.account_get('testuser') or { panic(err) }
|
||||
mailboxes := found_account.list_mailboxes()
|
||||
assert mailboxes.len == 2
|
||||
assert 'INBOX' in mailboxes
|
||||
assert 'Archives' in mailboxes
|
||||
|
||||
// Verify message in INBOX
|
||||
mut found_inbox := found_account.get_mailbox('INBOX') or { panic(err) }
|
||||
msgs := found_inbox.list() or { panic(err) }
|
||||
assert msgs.len == 1
|
||||
assert msgs[0].subject == 'Test message'
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
module server
|
||||
|
||||
import freeflowuniverse.herolib.servers.mail.mailbox
|
||||
import freeflowuniverse.herolib.servers.mail.imap
|
||||
import freeflowuniverse.herolib.servers.mail.smtp
|
||||
|
||||
pub fn start_demo() ! {
|
||||
// Create the server and initialize an example INBOX.
|
||||
|
||||
mut mailboxserver := mailbox.new_with_demo_data()!
|
||||
|
||||
// Use new from imap module and use mailboxserver as input
|
||||
mut imap_server := imap.new(mailboxserver)!
|
||||
mut smtp_server := smtp.new(mailboxserver)!
|
||||
|
||||
imap_server.start()!
|
||||
smtp_server.start()!
|
||||
|
||||
println('servers started.')
|
||||
|
||||
for {
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
## hero mail server
|
||||
|
||||
see examples/servers/imap_example.vsh for example
|
||||
|
||||
```v
|
||||
#!/usr/bin/env -S v -n -w -cg -gc none -cc tcc -d use_openssl -enable-globals run
|
||||
|
||||
import freeflowuniverse.herolib.servers.mail.server
|
||||
|
||||
// Start the IMAP server on port 143
|
||||
server.start_demo() !
|
||||
```
|
||||
@@ -1,121 +0,0 @@
|
||||
# SMTP Server
|
||||
|
||||
A simple SMTP server implementation in V that integrates with the mailbox module for message storage.
|
||||
|
||||
## Features
|
||||
|
||||
- SMTP server implementation with persistent storage via mailbox module
|
||||
- Support for basic SMTP commands (HELO/EHLO, MAIL FROM, RCPT TO, DATA)
|
||||
- Authentication support (PLAIN and LOGIN methods)
|
||||
- TLS capability advertising
|
||||
- Concurrent client handling
|
||||
- Integration with mailbox module for message storage
|
||||
|
||||
## Usage
|
||||
|
||||
The server can be started with a simple function call:
|
||||
|
||||
```v
|
||||
import freeflowuniverse.herolib.servers.mail.smtp
|
||||
import freeflowuniverse.herolib.servers.mail.mailbox
|
||||
|
||||
fn main() {
|
||||
// Create the mail server
|
||||
mut mailserver := mailbox.new_mail_server()
|
||||
|
||||
// Create the SMTP server wrapping the mail server
|
||||
mut smtp_server := smtp.new(mailserver)!
|
||||
|
||||
// Start the SMTP server on port 25
|
||||
smtp_server.start()!
|
||||
}
|
||||
```
|
||||
|
||||
Save this to `example.v` and run with:
|
||||
|
||||
```bash
|
||||
v run example.v
|
||||
```
|
||||
|
||||
The server will start listening on port 25 (default SMTP port).
|
||||
|
||||
## Testing with SMTP Client
|
||||
|
||||
You can test the server using any SMTP client. Here's an example using the `telnet` command:
|
||||
|
||||
```bash
|
||||
# Connect to server
|
||||
telnet localhost 25
|
||||
|
||||
# Example session:
|
||||
EHLO example.com
|
||||
MAIL FROM:<sender@example.com>
|
||||
RCPT TO:<recipient@example.com>
|
||||
DATA
|
||||
Subject: Test Message
|
||||
Hello World!
|
||||
.
|
||||
QUIT
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
The server consists of two main components:
|
||||
|
||||
1. **Mailbox Module** (`mailbox/`): Core mail functionality
|
||||
- User account management
|
||||
- Message storage and retrieval
|
||||
- Mailbox operations
|
||||
|
||||
2. **SMTP Server** (`smtp/`): SMTP protocol implementation
|
||||
- TCP connection handling and session management
|
||||
- SMTP command processing
|
||||
- Maps SMTP operations to mailbox module functionality
|
||||
- Concurrent client support
|
||||
|
||||
## Supported Commands
|
||||
|
||||
- `HELO/EHLO`: Initial greeting and capability negotiation
|
||||
- `MAIL FROM`: Specify sender address
|
||||
- `RCPT TO`: Specify recipient address(es)
|
||||
- `DATA`: Input email content
|
||||
- `RSET`: Reset session state
|
||||
- `QUIT`: End session
|
||||
- `AUTH`: Authentication (PLAIN/LOGIN methods)
|
||||
- `NOOP`: No operation
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
S: 220 SMTP server ready
|
||||
C: EHLO example.com
|
||||
S: 250-example.com
|
||||
S: 250-8BITMIME
|
||||
S: 250-PIPELINING
|
||||
S: 250-STARTTLS
|
||||
S: 250-AUTH PLAIN LOGIN
|
||||
S: 250 HELP
|
||||
C: MAIL FROM:<sender@example.com>
|
||||
S: 250 OK
|
||||
C: RCPT TO:<recipient@example.com>
|
||||
S: 250 OK
|
||||
C: DATA
|
||||
S: 354 Start mail input; end with <CRLF>.<CRLF>
|
||||
C: Subject: Test Email
|
||||
C:
|
||||
C: This is a test message.
|
||||
C: .
|
||||
S: 250 OK
|
||||
C: QUIT
|
||||
S: 221 Goodbye
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The server runs on port 25, which typically requires root privileges. Make sure you have the necessary permissions.
|
||||
- This is a basic implementation for demonstration purposes. For production use, consider adding:
|
||||
- TLS encryption implementation
|
||||
- Full message parsing and MIME support
|
||||
- More robust authentication
|
||||
- Rate limiting and spam protection
|
||||
- Extended SMTP features (SIZE, DSN, etc.)
|
||||
@@ -1,10 +0,0 @@
|
||||
module smtp
|
||||
|
||||
import freeflowuniverse.herolib.servers.mail.mailbox
|
||||
|
||||
pub fn new(mailboxserver &mailbox.MailServer) !SMTPServer {
|
||||
mut server := SMTPServer{
|
||||
mailboxserver: mailboxserver
|
||||
}
|
||||
return server
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
module smtp
|
||||
|
||||
import net
|
||||
import io
|
||||
import freeflowuniverse.herolib.servers.mail.mailbox
|
||||
|
||||
// SMTPServer wraps the mailbox server to provide SMTP functionality
|
||||
@[heap]
|
||||
pub struct SMTPServer {
|
||||
pub mut:
|
||||
mailboxserver &mailbox.MailServer
|
||||
}
|
||||
|
||||
// Session represents an active SMTP client connection
|
||||
pub struct Session {
|
||||
pub mut:
|
||||
server &SMTPServer
|
||||
conn net.TcpConn
|
||||
reader &io.BufferedReader
|
||||
tls_active bool
|
||||
helo_domain string
|
||||
mail_from string
|
||||
rcpt_to []string
|
||||
data_mode bool
|
||||
authenticated bool
|
||||
username string
|
||||
}
|
||||
|
||||
// State represents the current state of the SMTP session
|
||||
enum State {
|
||||
initial
|
||||
helo
|
||||
mail
|
||||
rcpt
|
||||
data
|
||||
quit
|
||||
}
|
||||
|
||||
// Response codes as defined in RFC 5321
|
||||
// Positive completion replies
|
||||
pub const reply_ready = 220 // Service ready
|
||||
|
||||
pub const reply_goodbye = 221 // Service closing transmission channel
|
||||
|
||||
pub const reply_ok = 250 // Requested mail action okay, completed
|
||||
|
||||
pub const reply_start_mail = 354 // Start mail input
|
||||
|
||||
// Permanent negative completion replies
|
||||
pub const reply_syntax_error = 500 // Syntax error, command unrecognized
|
||||
|
||||
pub const reply_syntax_error_params = 501 // Syntax error in parameters
|
||||
|
||||
pub const reply_not_implemented = 502 // Command not implemented
|
||||
|
||||
pub const reply_bad_sequence = 503 // Bad sequence of commands
|
||||
|
||||
pub const reply_auth_required = 530 // Authentication required
|
||||
|
||||
pub const reply_mailbox_unavailable = 550 // Mailbox unavailable
|
||||
|
||||
pub const reply_user_not_local = 551 // User not local
|
||||
|
||||
pub const reply_storage_exceeded = 552 // Requested mail action aborted: exceeded storage allocation
|
||||
|
||||
pub const reply_name_not_allowed = 553 // Requested action not taken: mailbox name not allowed
|
||||
|
||||
pub const reply_transaction_failed = 554 // Transaction failed
|
||||
|
||||
// send_response sends a formatted SMTP response to the client
|
||||
pub fn (mut self Session) send_response(code int, message string) ! {
|
||||
response := '${code} ${message}\r\n'
|
||||
self.conn.write(response.bytes())!
|
||||
}
|
||||
|
||||
// reset_session resets the session state for a new mail transaction
|
||||
pub fn (mut self Session) reset_session() {
|
||||
self.mail_from = ''
|
||||
self.rcpt_to = []string{}
|
||||
self.data_mode = false
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
module smtp
|
||||
|
||||
import net
|
||||
import io
|
||||
import freeflowuniverse.herolib.ui.console
|
||||
import freeflowuniverse.herolib.servers.mail.mailbox
|
||||
import time
|
||||
|
||||
// start starts the SMTP server on port 25 and accepts client connections
|
||||
pub fn (mut server SMTPServer) start() ! {
|
||||
spawn daemon(mut server)
|
||||
}
|
||||
|
||||
fn daemon(mut server SMTPServer) ! {
|
||||
addr := '0.0.0.0:25'
|
||||
mut listener := net.listen_tcp(.ip, addr, dualstack: true) or {
|
||||
return error('Failed to listen on ${addr}: ${err}')
|
||||
}
|
||||
println('SMTP Server listening on ${addr}')
|
||||
|
||||
for {
|
||||
mut conn := listener.accept() or {
|
||||
eprintln('Failed to accept connection: ${err}')
|
||||
continue
|
||||
}
|
||||
|
||||
conn.set_read_timeout(30 * time.second)
|
||||
conn.set_write_timeout(30 * time.second)
|
||||
|
||||
spawn handle_connection(mut conn, mut server)
|
||||
}
|
||||
}
|
||||
|
||||
// handle_connection processes commands from a connected SMTP client
|
||||
fn handle_connection(mut conn net.TcpConn, mut server SMTPServer) ! {
|
||||
defer {
|
||||
conn.close() or { panic(err) }
|
||||
}
|
||||
|
||||
mut reader := io.new_buffered_reader(reader: conn)
|
||||
defer {
|
||||
unsafe {
|
||||
reader.free()
|
||||
}
|
||||
}
|
||||
|
||||
mut session := Session{
|
||||
server: &server
|
||||
conn: conn
|
||||
reader: reader
|
||||
tls_active: false
|
||||
authenticated: false
|
||||
}
|
||||
|
||||
client_addr := conn.peer_addr()!
|
||||
console.print_debug('> new SMTP client: ${client_addr}')
|
||||
|
||||
// Send initial greeting
|
||||
session.send_response(reply_ready, 'SMTP server ready')!
|
||||
|
||||
for {
|
||||
// Read a line (command) from the client
|
||||
line := reader.read_line() or {
|
||||
match err.msg() {
|
||||
'closed' {
|
||||
console.print_debug('Client disconnected normally')
|
||||
return error('client disconnected')
|
||||
}
|
||||
'EOF' {
|
||||
console.print_debug('Client connection ended (EOF)')
|
||||
return error('connection ended')
|
||||
}
|
||||
else {
|
||||
eprintln('Connection read error: ${err}')
|
||||
return error('connection error: ${err}')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.print_debug('< ${line}')
|
||||
trimmed := line.trim_space()
|
||||
if trimmed.len == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if session.data_mode {
|
||||
handle_data_content(mut session, trimmed)!
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse command and parameters
|
||||
parts := trimmed.split(' ')
|
||||
cmd := parts[0].to_upper()
|
||||
|
||||
match cmd {
|
||||
'HELO', 'EHLO' {
|
||||
handle_helo(mut session, parts)!
|
||||
}
|
||||
'MAIL' {
|
||||
handle_mail(mut session, parts)!
|
||||
}
|
||||
'RCPT' {
|
||||
handle_rcpt(mut session, parts)!
|
||||
}
|
||||
'DATA' {
|
||||
handle_data(mut session)!
|
||||
}
|
||||
'RSET' {
|
||||
handle_rset(mut session)!
|
||||
}
|
||||
'QUIT' {
|
||||
handle_quit(mut session)!
|
||||
return
|
||||
}
|
||||
'AUTH' {
|
||||
handle_auth(mut session, parts)!
|
||||
}
|
||||
'NOOP' {
|
||||
session.send_response(reply_ok, 'OK')!
|
||||
}
|
||||
else {
|
||||
session.send_response(reply_syntax_error, 'Command not recognized')!
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handle_helo processes HELO/EHLO commands
|
||||
fn handle_helo(mut session Session, parts []string) ! {
|
||||
if parts.len < 2 {
|
||||
session.send_response(reply_syntax_error_params, 'Missing domain parameter')!
|
||||
return
|
||||
}
|
||||
|
||||
session.helo_domain = parts[1]
|
||||
mut capabilities := ['8BITMIME', 'PIPELINING']
|
||||
|
||||
if !session.tls_active {
|
||||
capabilities << 'STARTTLS'
|
||||
}
|
||||
if !session.authenticated {
|
||||
capabilities << 'AUTH PLAIN LOGIN'
|
||||
}
|
||||
|
||||
if parts[0].to_upper() == 'EHLO' {
|
||||
// Send multi-line EHLO response
|
||||
session.send_response(reply_ok, '${session.helo_domain}')!
|
||||
for cap in capabilities {
|
||||
session.conn.write('250-${cap}\r\n'.bytes())!
|
||||
}
|
||||
session.conn.write('250 HELP\r\n'.bytes())!
|
||||
} else {
|
||||
// Simple HELO response
|
||||
session.send_response(reply_ok, '${session.helo_domain}')!
|
||||
}
|
||||
}
|
||||
|
||||
// handle_mail processes MAIL FROM command
|
||||
fn handle_mail(mut session Session, parts []string) ! {
|
||||
if session.helo_domain == '' {
|
||||
session.send_response(reply_bad_sequence, 'Please send HELO/EHLO first')!
|
||||
return
|
||||
}
|
||||
|
||||
if parts.len < 2 {
|
||||
session.send_response(reply_syntax_error_params, 'Missing FROM parameter')!
|
||||
return
|
||||
}
|
||||
|
||||
from_part := parts[1].to_upper()
|
||||
if !from_part.starts_with('FROM:') {
|
||||
session.send_response(reply_syntax_error_params, 'Syntax error in FROM parameter')!
|
||||
return
|
||||
}
|
||||
|
||||
// Extract email address from <...>
|
||||
addr_start := from_part.index('<') or { -1 }
|
||||
addr_end := from_part.last_index('>') or { -1 }
|
||||
if addr_start == -1 || addr_end == -1 || addr_start >= addr_end {
|
||||
session.send_response(reply_syntax_error_params, 'Invalid email address format')!
|
||||
return
|
||||
}
|
||||
|
||||
session.mail_from = from_part[addr_start + 1..addr_end]
|
||||
session.send_response(reply_ok, 'OK')!
|
||||
}
|
||||
|
||||
// handle_rcpt processes RCPT TO command
|
||||
fn handle_rcpt(mut session Session, parts []string) ! {
|
||||
if session.mail_from == '' {
|
||||
session.send_response(reply_bad_sequence, 'Need MAIL FROM command first')!
|
||||
return
|
||||
}
|
||||
|
||||
if parts.len < 2 {
|
||||
session.send_response(reply_syntax_error_params, 'Missing TO parameter')!
|
||||
return
|
||||
}
|
||||
|
||||
to_part := parts[1].to_upper()
|
||||
if !to_part.starts_with('TO:') {
|
||||
session.send_response(reply_syntax_error_params, 'Syntax error in TO parameter')!
|
||||
return
|
||||
}
|
||||
|
||||
// Extract email address from <...>
|
||||
addr_start := to_part.index('<') or { -1 }
|
||||
addr_end := to_part.last_index('>') or { -1 }
|
||||
if addr_start == -1 || addr_end == -1 || addr_start >= addr_end {
|
||||
session.send_response(reply_syntax_error_params, 'Invalid email address format')!
|
||||
return
|
||||
}
|
||||
|
||||
rcpt_addr := to_part[addr_start + 1..addr_end]
|
||||
|
||||
// Verify recipient exists in mailbox server
|
||||
username := session.server.mailboxserver.account_find_by_email(rcpt_addr) or {
|
||||
session.send_response(reply_mailbox_unavailable, 'No such user here')!
|
||||
return
|
||||
}
|
||||
|
||||
session.rcpt_to << rcpt_addr
|
||||
session.send_response(reply_ok, 'OK')!
|
||||
}
|
||||
|
||||
// handle_data processes DATA command
|
||||
fn handle_data(mut session Session) ! {
|
||||
if session.rcpt_to.len == 0 {
|
||||
session.send_response(reply_bad_sequence, 'Need RCPT TO command first')!
|
||||
return
|
||||
}
|
||||
|
||||
session.data_mode = true
|
||||
session.send_response(reply_start_mail, 'Start mail input; end with <CRLF>.<CRLF>')!
|
||||
}
|
||||
|
||||
// handle_data_content processes the email content after DATA command
|
||||
fn handle_data_content(mut session Session, line string) ! {
|
||||
if line == '.' {
|
||||
// End of data
|
||||
session.data_mode = false
|
||||
|
||||
// Store message for each recipient
|
||||
for rcpt in session.rcpt_to {
|
||||
username := session.server.mailboxserver.account_find_by_email(rcpt) or {
|
||||
eprintln('Failed to find recipient ${rcpt}')
|
||||
continue
|
||||
}
|
||||
|
||||
// Create message in recipient's INBOX
|
||||
mut msg := mailbox.Message{
|
||||
uid: 0 // Will be assigned by mailbox
|
||||
subject: 'New message' // TODO: Parse subject from headers
|
||||
body: line // TODO: Accumulate message body
|
||||
flags: []string{} // No flags initially
|
||||
internal_date: time.now()
|
||||
}
|
||||
session.server.mailboxserver.message_set(username, 'INBOX', 0, msg) or {
|
||||
eprintln('Failed to store message for ${username}: ${err}')
|
||||
session.send_response(reply_transaction_failed, 'Failed to store message')!
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
session.send_response(reply_ok, 'OK')!
|
||||
session.reset_session()
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Accumulate message body
|
||||
// For now we just acknowledge the line
|
||||
}
|
||||
|
||||
// handle_rset processes RSET command
|
||||
fn handle_rset(mut session Session) ! {
|
||||
session.reset_session()
|
||||
session.send_response(reply_ok, 'OK')!
|
||||
}
|
||||
|
||||
// handle_quit processes QUIT command
|
||||
fn handle_quit(mut session Session) ! {
|
||||
session.send_response(reply_goodbye, 'Goodbye')!
|
||||
}
|
||||
|
||||
// handle_auth processes AUTH command
|
||||
fn handle_auth(mut session Session, parts []string) ! {
|
||||
if parts.len < 2 {
|
||||
session.send_response(reply_syntax_error_params, 'Missing authentication type')!
|
||||
return
|
||||
}
|
||||
|
||||
auth_type := parts[1].to_upper()
|
||||
if auth_type !in ['PLAIN', 'LOGIN'] {
|
||||
session.send_response(reply_syntax_error_params, 'Unsupported authentication type')!
|
||||
return
|
||||
}
|
||||
|
||||
// For demo purposes, accept any credentials
|
||||
if auth_type == 'PLAIN' {
|
||||
if parts.len < 3 {
|
||||
session.send_response(reply_syntax_error_params, 'Missing credentials')!
|
||||
return
|
||||
}
|
||||
// In real implementation, decode base64 credentials and validate
|
||||
session.authenticated = true
|
||||
session.send_response(reply_ok, 'Authentication successful')!
|
||||
} else { // LOGIN
|
||||
// Send username prompt
|
||||
session.conn.write('334 VXNlcm5hbWU6\r\n'.bytes())! // Base64 encoded "Username:"
|
||||
username := session.reader.read_line()!
|
||||
|
||||
// Send password prompt
|
||||
session.conn.write('334 UGFzc3dvcmQ6\r\n'.bytes())! // Base64 encoded "Password:"
|
||||
password := session.reader.read_line()!
|
||||
|
||||
// For demo purposes, accept any credentials
|
||||
session.authenticated = true
|
||||
session.username = username // Store for potential use
|
||||
session.send_response(reply_ok, 'Authentication successful')!
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user