diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ffed35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,76 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ +pubspec.lock + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..d536721 --- /dev/null +++ b/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: f4abaa0735eba4dfd8f33f73363911d63931fe03 + channel: stable + +project_type: package diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b040a2c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +## 0.1.0 +* Initial release with iMIP support. diff --git a/README.md b/README.md index 989f1ea..9ab4771 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,105 @@ # enough_mail_icalendar -iCalendar support for email / mime. Compatible with iCalendar Message-Based Interoperability Protocol (iMIP) RFC 6047. +iCalendar support for email / mime. Compatible with the iCalendar Message-Based Interoperability Protocol (iMIP) [RFC 6047](https://datatracker.ietf.org/doc/html/rfc6047). + +## Installation +Add this dependency your pubspec.yaml file: + +``` +dependencies: + enough_mail_icalendar: ^0.1.0 + enough_mail: latest + enough_icalendar: latest +``` +The latest version or `enough_mail_icalendar` is [![enough_mail_icalendar version](https://img.shields.io/pub/v/enough_mail_icalendar.svg)](https://pub.dartlang.org/packages/enough_mail_icalendar). + + + +## API Documentation +Check out the full API documentation at https://pub.dev/documentation/enough_mail_icalendar/latest/ + +## Usage + +Use `enough_mail_icalendar` to generate and send MIME email messages for iCalendar requests. + +### Import + +```dart +import 'package:enough_icalendar/enough_icalendar.dart'; +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail_icalendar/enough_mail_icalendar.dart'; +``` +### Generate a MimeMessage for a VCalendar +With `VMessageBuilder.prepareFromCalendar(...)` create a MIME message builder for a given `VCalendar` object. + +```dart +void buildCalendarInviteMessage(VCalendar invite) { + final builder = VMessageBuilder.prepareFromCalendar(invite); + final mimeMessage = builder.buildMimeMessage(); + print(mimeMessage); + // you can now send the MimeMessage as any other message, e.g. with `MailClient.sendMessage(mimeMessage)` +} +``` +### Generate a Reply MimeMessage for a Received VCalendar +Use `VMessageBuilder.prepareCalendarReply(...)` to create a reply MIME message for a received VCalendar. +In the following example the invite is accepted. +```dart +void buildAcceptReplyMessage(VCalendar invite) { + final me = MailAddress('Donna Strickland', 'b@example.com'); + final acceptMessageBuilder = VMessageBuilder.prepareCalendarReply( + invite, + ParticipantStatus.accepted, + me, + ); + final mimeMessage = acceptMessageBuilder.buildMimeMessage(); + print(mimeMessage); +} +``` +### Send a Reply directly for a Received VCalendar +Send a reply directly with the `MailClient.sendCalendarReply()` instance method. This will generate the +mime message, send it and update the originating message's flags, when the message is specified and when the +mail service supports arbitrary message flags. +```dart +Future sendCalendarReply( + VCalendar calendar, + ParticipantStatus participantStatus, + MimeMessage originatingMessage, + MailClient mailClient, +) { + // generate reply email message, send it, set message flags: + return mailClient.sendCalendarReply(calendar, participantStatus, + originatingMessage: originatingMessage); +} +``` + +### Check if a Reply has been Send for a MimeMessage +Use the `calendarParticipantStatus` getter on a `MimeMessage` instance to check for participation status flags that have been set earlier. +```dart +ParticipantStatus? getParticipantStatus(MimeMessage message) { + // the ParticipantStatus can be detected from the message flags when + //the flag was added successfully before + final participantStatus = message.calendarParticipantStatus; + if (participantStatus != null) { + print( + 'detected ${participantStatus.name} through flag ${participantStatus.flag}'); + } else { + print('no participant status flag detected in ${message.flags}'); + } + return participantStatus; +} +``` + +## Features and bugs + +`enough_mail_icalendar` should be fully compliant with the iCalendar Message-Based Interoperability Protocol (iMIP) [RFC 6047](https://datatracker.ietf.org/doc/html/rfc6047). + + +Please file feature requests and bugs at the [issue tracker][tracker]. + +[tracker]: https://github.com/Enough-Software/enough_mail_icalendar/issues + +## Null-Safety +`enough_mail_icalendar` is null-safe. + +## License +`enough_mail_icalendar` is licensed under the commercial friendly [Mozilla Public License 2.0](LICENSE) + diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..026ff19 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +package:lints/recommended.yaml \ No newline at end of file diff --git a/example/enough_mail_icalendar_example.dart b/example/enough_mail_icalendar_example.dart new file mode 100644 index 0000000..0240555 --- /dev/null +++ b/example/enough_mail_icalendar_example.dart @@ -0,0 +1,77 @@ +import 'package:enough_icalendar/enough_icalendar.dart'; +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail_icalendar/enough_mail_icalendar.dart'; + +void main() { + final invite = createInvite(); + buildCalendarInviteMessage(invite); + buildAcceptReplyMessage(invite); +} + +void buildCalendarInviteMessage(VCalendar invite) { + final builder = VMessageBuilder.prepareFromCalendar(invite); + final mimeMessage = builder.buildMimeMessage(); + print('=========================='); + print('invite message:'); + print('=========================='); + print(mimeMessage); + // you can now send the MimeMessage as any other message, e.g. with `MailClient.sendMessage(mimeMessage)` +} + +void buildAcceptReplyMessage(VCalendar invite) { + final me = MailAddress('Donna Strickland', 'b@example.com'); + final acceptMessageBuilder = VMessageBuilder.prepareCalendarReply( + invite, + ParticipantStatus.accepted, + me, + ); + final mimeMessage = acceptMessageBuilder.buildMimeMessage(); + print('\n=========================='); + print('reply message:'); + print('=========================='); + print(mimeMessage); +} + +Future sendCalendarReply( + VCalendar calendar, + ParticipantStatus participantStatus, + MimeMessage originatingMessage, + MailClient mailClient, +) { + // generate reply email message, send it, set message flags: + return mailClient.sendCalendarReply(calendar, participantStatus, + originatingMessage: originatingMessage); +} + +ParticipantStatus? getParticipantStatus(MimeMessage message) { + // the ParticipantStatus can be detected from the message flags when + //the flag was added successfully before + final participantStatus = message.calendarParticipantStatus; + if (participantStatus != null) { + print( + 'detected ${participantStatus.name} through flag ${participantStatus.flag}'); + } else { + print('no participant status flag detected in ${message.flags}'); + } + return participantStatus; +} + +VCalendar createInvite() { + final me = MailAddress('Andrea Ghez', 'a@example.com'); + final invitees = [ + MailAddress('Andrea Ghez', 'a@example.com'), + MailAddress('Donna Strickland', 'b@example.com'), + MailAddress('Maria Goeppert Mayer', 'c@example.com'), + MailAddress('Marie Curie, née Sklodowska', 'c@example.com'), + ]; + final invite = VCalendar.createEvent( + start: DateTime(2021, 08, 01, 11, 00), + duration: IsoDuration(hours: 1), + organizer: me.organizer, + attendees: invitees.map((address) => address.attendee).toList(), + location: 'Stockholm', + summary: 'Physics Winners', + description: 'Let\'s discuss what to research next.', + ); + return invite; +} diff --git a/lib/enough_mail_icalendar.dart b/lib/enough_mail_icalendar.dart new file mode 100644 index 0000000..d30054d --- /dev/null +++ b/lib/enough_mail_icalendar.dart @@ -0,0 +1,7 @@ +/// A library for handling iCalendar invites in email. +/// +/// Compatible with the iCalendar Message-Based Interoperability Protocol (iMIP), [RFC 6047](https://datatracker.ietf.org/doc/html/rfc6047) +library enough_mail_icalendar; + +export 'src/builder.dart'; +export 'src/extensions.dart'; diff --git a/lib/src/builder.dart b/lib/src/builder.dart new file mode 100644 index 0000000..9c9e875 --- /dev/null +++ b/lib/src/builder.dart @@ -0,0 +1,93 @@ +import 'package:enough_icalendar/enough_icalendar.dart'; +import 'package:enough_mail/enough_mail.dart'; +import 'extensions.dart'; + +class VMessageBuilder { + VMessageBuilder._(); + + /// Prepares a message builder with a request for the specified [calendar]. + static MessageBuilder prepareFromCalendar( + VCalendar calendar, { + List toRecipients = const [], + List ccRecipients = const [], + List bccRecipients = const [], + String? plaintTextPart, + MailAddress? from, + String filename = 'invite.ics', + String? subject, + }) { + final organizer = calendar.organizer; + if (from == null && (organizer == null || organizer.email == null)) { + throw StateError( + 'Either the [from] parameter must be set or the calendar requires a child with an [organizer] set.'); + } + + final builder = MessageBuilder.prepareMultipartMixedMessage(); + builder.subject = subject ?? calendar.summary ?? 'Invite'; + final fromSender = from ?? calendar.organizerMailAddress!; + builder.from = [fromSender]; + if (toRecipients.isEmpty && ccRecipients.isEmpty && bccRecipients.isEmpty) { + final attendees = calendar.attendees; + if (attendees == null || attendees.isEmpty) { + throw StateError( + 'Warning: neither recipients specified nor attendees found in calendar'); + } + builder.to = calendar.attendeeMailAddresses; + } else { + builder.to = toRecipients; + builder.cc = ccRecipients; + builder.bcc = bccRecipients; + } + final text = plaintTextPart ?? + calendar.description ?? + calendar.summary ?? + 'This message contains an calendar invite'; + builder.addTextPlain(text); + final calendarPart = builder.addText( + calendar.toString(), + mediaType: MediaSubtype.textCalendar.mediaType, + disposition: ContentDispositionHeader.from( + ContentDisposition.attachment, + filename: filename, + ), + ); + if (calendar.method != null) { + final contentType = calendarPart.contentType!; + contentType.parameters['method'] = calendar.method!.name; + } + return builder; + } + + static MessageBuilder prepareCalendarReply( + VCalendar calendar, + ParticipantStatus participantStatus, + MailAddress from, { + String? comment, + String productId = 'enough_mail with enough_icalendar', + String icsFilename = 'reply.ics', + }) { + final organizer = calendar.organizer; + if (organizer == null || organizer.email == null) { + throw StateError( + 'VCALENDAR has no organizer or the organizer has no email: $organizer'); + } + final reply = calendar.replyWithParticipantStatus( + participantStatus, + attendeeEmail: from.email, + comment: comment, + productId: productId, + ); + final subject = + MessageBuilder.createReplySubject(calendar.summary ?? 'Invite'); + final messageBuilder = prepareFromCalendar( + reply, + from: from, + toRecipients: [organizer.mailAddress!], + filename: icsFilename, + plaintTextPart: comment ?? + '"${calendar.summary}" is ${participantStatus.name} by $from.', + subject: subject, + ); + return messageBuilder; + } +} diff --git a/lib/src/extensions.dart b/lib/src/extensions.dart new file mode 100644 index 0000000..a751130 --- /dev/null +++ b/lib/src/extensions.dart @@ -0,0 +1,142 @@ +import 'package:enough_icalendar/enough_icalendar.dart'; +import 'package:enough_mail/enough_mail.dart'; +import 'package:collection/collection.dart' show IterableExtension; + +import 'builder.dart'; + +const String _icalFlagHeader = '\$ical'; + +extension ExtensionMailClient on MailClient { + /// Changes the [participantStatus] for the given[calandar], e.g. to accept or decline a meeting request, generates and sends the corresponding message. + /// + /// When you specify the [originatingMessage] a flag will tried be stored marking this message as replied with the given [particpantStatus]. Compare [ExtensionMimeMessage.calendarParticipationStaus] getter. + /// Optionally specify a [comment], the [productId] that ends up in the generated [VCalendar] reply + /// and the [icsFilename] that defaults to `reply.ics`. + Future sendCalendarReply( + VCalendar calendar, + ParticipantStatus participantStatus, { + MimeMessage? originatingMessage, + String? comment, + String productId = 'enough_mail with enough_icalendar', + String icsFilename = 'reply.ics', + }) async { + final messageBuilder = VMessageBuilder.prepareCalendarReply( + calendar, + participantStatus, + account.fromAddress, + comment: comment, + productId: productId, + icsFilename: icsFilename, + ); + await sendMessage(messageBuilder.buildMimeMessage()); + if (originatingMessage != null) { + // flag originating message as replied with the participant status: + final flagName = '$_icalFlagHeader${participantStatus.name!}'; + if (!originatingMessage.hasFlag(flagName)) { + final existingIcalFlags = originatingMessage.flags + ?.where((flag) => flag.startsWith(_icalFlagHeader)) ?? + []; + final sequence = MessageSequence.fromMessage(originatingMessage); + try { + await store(sequence, [flagName, MessageFlags.answered], + action: StoreAction.add); + if (existingIcalFlags.isNotEmpty) { + await store(sequence, existingIcalFlags.toList(), + action: StoreAction.remove); + } + } catch (e, s) { + print('Unable to store flag $flagName: $e $s'); + } + } + } + } + + /// Sends out the given [calendar] invite. + /// + /// Optionally specify an explanation text in [plainTextPart] and specify the [icsFilename]. + Future sendCalendarInvite(VCalendar calendar, + {String? plainTextPart, String icsFilename = 'invite.ics'}) async { + final messageBuilder = VMessageBuilder.prepareFromCalendar( + calendar, + from: account.fromAddress, + filename: icsFilename, + plaintTextPart: plainTextPart, + ); + await sendMessage(messageBuilder.buildMimeMessage()); + } +} + +extension ExtensionMimeMessage on MimeMessage { + /// Retriees the participant status from the flags of this message + ParticipantStatus? get calendarParticipantStatus { + final flag = + flags?.firstWhereOrNull((flag) => flag.startsWith(_icalFlagHeader)); + if (flag != null) { + try { + return ParticipantStatusParameter.parse( + flag.substring(_icalFlagHeader.length)); + } catch (e) { + print('Warning: unknown iCal ParticipatonStatus flag found: [$flag]'); + } + } + return null; + } +} + +extension ExtensionCalendar on VCalendar { + /// Retrieves the attendee mailing addresses from the first component with a attendees getter. + List? get attendeeMailAddresses => attendees + ?.where((attendee) => attendee.email != null) + .map((attendee) => MailAddress(attendee.commonName, attendee.email!)) + .toList(); + + /// Gets the organizer as a [MailAddress] from the first component with an organizer getter. + MailAddress? get organizerMailAddress { + final o = organizer; + if (o == null || o.email == null) { + return null; + } + return MailAddress(o.commonName, o.email!); + } +} + +extension ExtensionAttendeeProperty on UserProperty { + /// Retrieves the mail address + MailAddress? get mailAddress { + final mail = email; + if (mail == null) { + return null; + } + return MailAddress(commonName, mail); + } +} + +extension ExtensionMailAddress on MailAddress { + /// Converts this mail address to an iCalendar attendee property + AttendeeProperty get attendee => + AttendeeProperty.create(attendeeEmail: email, commonName: personalName)!; + + /// Converts this mail address to an iCalendar organizer property + OrganizerProperty get organizer => + OrganizerProperty.create(email: email, commonName: personalName)!; +} + +extension ExtensionMimePart on MimePart { + /// Decodes this mime message part's content as a [VCalendar], if possible. + VCalendar? decodeContentVCalendar() { + if (mediaType.sub != MediaSubtype.textCalendar) { + return null; + } + final text = decodeContentText(); + if (text == null) { + return null; + } + final component = VComponent.parse(text); + return component as VCalendar?; + } +} + +extension ExtensionParticipantStatus on ParticipantStatus { + /// Retrieves the IMAP flag name for this participant status + String get flag => '$_icalFlagHeader$name'; +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..efb9898 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,19 @@ +name: enough_mail_icalendar +description: iCalendar support for email / mime. Compatible with iCalendar Message-Based Interoperability Protocol (iMIP) RFC 6047. +version: 0.1.0 +homepage: https://github.com/Enough-Software/enough_mail_icalendar + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=1.17.0" + +dependencies: + collection: ^1.15.0 + enough_mail: ^1.3.6 + enough_icalendar: ^0.3.1 +# path: ../enough_icalendar + +dev_dependencies: + flutter_test: + sdk: flutter + lints: ^1.0.1 diff --git a/test/enough_mail_icalendar_test.dart b/test/enough_mail_icalendar_test.dart new file mode 100644 index 0000000..aea11b2 --- /dev/null +++ b/test/enough_mail_icalendar_test.dart @@ -0,0 +1,94 @@ +import 'package:enough_icalendar/enough_icalendar.dart'; +import 'package:enough_mail/enough_mail.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:enough_mail_icalendar/enough_mail_icalendar.dart'; + +void main() { + test('create invite message', () { + final me = MailAddress('Andrea Ghez', 'a@example.com'); + final invitees = [ + MailAddress('Andrea Ghez', 'a@example.com'), + MailAddress('Donna Strickland', 'b@example.com'), + MailAddress('Maria Goeppert Mayer', 'c@example.com'), + MailAddress('Marie Curie, née Sklodowska', 'c@example.com'), + ]; + final invite = VCalendar.createEvent( + start: DateTime(2021, 08, 01, 11, 00), + duration: IsoDuration(hours: 1), + organizer: me.organizer, + attendees: invitees.map((address) => address.attendee).toList(), + location: 'Stockholm', + summary: 'Physics Winners', + description: 'Let\'s discuss what to research next.', + ); + final builder = VMessageBuilder.prepareFromCalendar(invite); + final message = builder.buildMimeMessage(); + // print(message); + expect(message.decodeSubject(), 'Physics Winners'); + expect(message.mediaType.sub, MediaSubtype.multipartMixed); + expect(message.fromEmail, 'a@example.com'); + expect(message.to, isNotEmpty); + expect(message.to?.length, 4); + expect(message.to![3].personalName, 'Marie Curie, née Sklodowska'); + expect(message.parts, isNotEmpty); + expect(message.parts?.length, 2); + expect(message.parts![0].mediaType.sub, MediaSubtype.textPlain); + expect(message.parts![1].mediaType.sub, MediaSubtype.textCalendar); + final decodedInvite = message.parts![1].decodeContentVCalendar(); + // print(decodedInvite); + expect(decodedInvite, isNotNull); + expect(decodedInvite!.description, 'Let\'s discuss what to research next.'); + expect(decodedInvite.method, Method.request); + }); + + test('Create participation response message', () { + final inviteText = + '''BEGIN:VCALENDAR +PRODID:enough_icalendar +VERSION:2.0 +METHOD:REQUEST +BEGIN:VEVENT +DTSTAMP:20210720T165936 +UID:ct8Vh0bF5o28nLO2A2@example.com +DTSTART:20210801T110000 +DURATION:PT1H0M0S +ORGANIZER;CN=Andrea Ghez:mailto:a@example.com +SUMMARY:Physics Winners +DESCRIPTION:Let's discuss what to research next. +LOCATION:Stockholm +ATTENDEE;CN=Andrea Ghez:mailto:a@example.com +ATTENDEE;CN=Donna Strickland:mailto:b@example.com +ATTENDEE;CN=Maria Goeppert Mayer:mailto:c@example.com +ATTENDEE;CN="Marie Curie, née Sklodowska":mailto:c@example.com +END:VEVENT +END:VCALENDAR'''; + final invite = VComponent.parse(inviteText) as VCalendar; + final me = MailAddress('Donna Strickland', 'b@example.com'); + final acceptMessageBuilder = VMessageBuilder.prepareCalendarReply( + invite, + ParticipantStatus.accepted, + me, + ); + final message = acceptMessageBuilder.buildMimeMessage(); + //print(message); + expect(message.decodeSubject(), 'Re: Physics Winners'); + expect(message.mediaType.sub, MediaSubtype.multipartMixed); + expect(message.fromEmail, 'b@example.com'); + expect(message.to, isNotEmpty); + expect(message.to?.length, 1); + expect(message.to![0].personalName, 'Andrea Ghez'); + expect(message.parts, isNotEmpty); + expect(message.parts?.length, 2); + expect(message.parts![0].mediaType.sub, MediaSubtype.textPlain); + expect(message.parts![1].mediaType.sub, MediaSubtype.textCalendar); + final decodedReply = message.parts![1].decodeContentVCalendar(); + // print(decodedInvite); + expect(decodedReply, isNotNull); + expect(decodedReply!.attendees, isNotEmpty); + expect(decodedReply.attendees?.first.participantStatus, + ParticipantStatus.accepted); + expect(decodedReply.uid, invite.uid); + expect(decodedReply.method, Method.reply); + }); +}