This commit is contained in:
2025-02-17 06:41:19 +03:00
parent 52aba347a8
commit 8c52326550
64 changed files with 0 additions and 25691 deletions

View File

@@ -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() !

View File

@@ -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}') }
}
}

View File

@@ -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}') }
}
}

View File

@@ -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
}

View File

@@ -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}') }
}
}

View File

@@ -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
}

View File

@@ -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'
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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 []
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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}') }
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)!
}

View File

@@ -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
}

View File

@@ -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')
}

View File

@@ -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'
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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())!
}
}

View File

@@ -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())!
}

View File

@@ -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())!
}

View File

@@ -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())!
}

View File

@@ -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())!
}

View File

@@ -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())!
}

View File

@@ -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())!
}

View File

@@ -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())!
}

View File

@@ -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())!
}

View File

@@ -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())!
}
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)!
}
}
}

View File

@@ -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']
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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')
}
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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'
}

View File

@@ -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 {
}
}

View File

@@ -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() !
```

View File

@@ -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.)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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