Recientemente he finalizado, junto con el equipo de Sharepoint, un proyecto interesante en cuanto al nivel de personalización que el cliente exigía.
El requerimiento era crear un workflow de aprobación reusable para formularios de solicitud de empleo. Lo significativo de este workflow era que debía trabajar sin lista de tareas ( esencial en los workflows de aprobación de Sharepoint ) y con emails personalizados, los cuales debían llevar adjunto el formulario (hecho en InfoPath), de manera que los usuarios involucrados en el proceso de solicitud, puedan abrirlos desde Outlook y completarlos.
Desde el inicio pensamos en una Custom Activity que pueda cumplir con el requerimiento de envío de mails .
En esta oportunidad vamos a ver como crear una Custom Activity para enviar mails con formularios InfoPath adjuntos para poder usarla desde el Sharepoint Designer en el diseño de una solución.
1 - Creación de la estructura de la solución.
Para comenzar vamos a crear un Empty SharePoint Project en Visual Studio 2010.
Le damos nombre al proyecto y seleccionamos “Ok”. Se abrirá una ventana en la cual deberemos especificar el Servidor de Sharepoint donde se hará el deploy de la solución, y si lo queremos hacer como Sandboxed Solution o FarmSolution. En nuestro ejemplo vamos a usar una Farm Solution.

Finalizamos, y Visual Studio nos creará un proyecto en base al template Seleccionado que se verá como la siguiente imagen.
Vamos a modificar el nombre del archivo key.snk por el nombre de nuestro proyecto, en este caso VEMN.CustomActivity. Esto es mas que nada para mantener un orden y una correspondencia.
Una vez hecho esto, agregamos una Feature, le damos un nombre, siempre es preferible usar el mismo nombre del proyecto para mantener una coherencia.

Luego vamos a agregar una Sharepoint Mapped Folder. Nos paramos sobre el proyecto, Clik Derecho –> Add –> Sharepoint Mapped Folder. Es de vital importancia el siguiente paso ya que este cambia de acuerdo al lenguaje en que esté nuestro Sitio. Como el nuestro está inglés seleccionaremos la ruta …/TEMPLATE/1033/Workflow. En caso de que nuestro sitio este en Español debemos seleccionar el directorio 3082 o el código que le corresponda.

Esto mapea esta carpeta a nuestro proyecto en donde vamos a agregar un archivo xml que va a contener la metadata y las definiciones de parámetros de entrada y salida de nuestra Custom Activity. Este lo llamaremos con mismo nombre que el proyecto y la extensión se la cambiaremos de .xml a .actions, como se puede ver en la imagen siguiente.

2 - Definición del archivo ACTIONS
Definiremos a continuación, el archivo .actions que acabamos de agregar. Es importante tener en cuenta que este archivo es leído por el SPD para levantar nuestra custom activity y habilitarla en el menú Action. Por lo cual deberemos prestar mucha atención al momento de crearlo, para no tener problemas en la implementación.
Nuestro xml debe quedar con las siguiente estructura. Luego discutiremos sobre que es cada cosa:
1: <WorkflowInfo Language="en-US">
2: <Actions Sequential="then" Parallel="and">
3: <Action Name="Send mail with attachment infopath form"
4: ClassName="VEMN.CustomActivity.SendMailActivity"
5: Assembly="VEMN.CustomActivity,Version=1.0.0.0,Culture=neutral,PublicKeyToken=7819e4834cee4b0e"
6: AppliesTo="all"
7: Category="Custom Actions">
8: <RuleDesigner Sentence="Send mail with this %1 attachment to %2. Use %3 like sender. >
9: <FieldBind Field="AttachmentFileName" Text="form (url)" DesignerType="TextArea" Id="1"/>
10: <FieldBind Field="To,CC,Subject,Body" Text="these user(s)" DesignerType="Email" Id="2"/>
11: <FieldBind Field="From" Text="this user" DesignerType="TextArea" Id="3"/>
12: </RuleDesigner>
13: <Parameters>
14: <Parameter Name="__Context" Type="Microsoft.SharePoint.WorkflowActions.WorkflowContext" Direction="In" />
15: <Parameter Name="__ListId" Type="System.String, mscorlib" Direction="In" />
16: <Parameter Name="__ListItem" Type="System.Int32, mscorlib" Direction="In" />
17: <Parameter Name="__ActivationProperties" Type="Microsoft.SharePoint.Workflow.SPWorkflowActivationProperties, Microsoft.SharePoint" Direction="Out" />
18: <Parameter Name="AttachmentFileName" Type="System.String, mscorlib" Direction="In" />
19: <Parameter Name="To" Type="System.Collections.ArrayList, mscorlib" Direction="In" />
20: <Parameter Name="CC" Type="System.Collections.ArrayList, mscorlib" Direction="Optional" />
21: <Parameter Name="Subject" Type="System.String, mscorlib" Direction="In" />
22: <Parameter Name="Body" Type="System.String, mscorlib" Direction="Optional" />
23: <Parameter Name="From" Type="System.String, mscorlib" Direction="In" />
24: </Parameters>
25: </Action>
26: </Actions>
27: </WorkflowInfo>
El nodo WorkflowInfo solo definimos el lenguaje del sitio en el cual vamos a utilizar nuestra funcionalidad. El nodo Actions define la forma en el texto se construye luego de la descripción de la acción en el diseñador del flujo de trabajo. Por ejemplo, en una acción secuencial seria algo así: “<actiondescription> then”.
Lo realmente interesante está en el nodo Action, en el, los atributos Name, ClassName, Assembly, ApplyTo and Category definen: el nombre de la acción en el Menú de Acciones (o Actions Menu), la Clase y el Assembly para el código, si se refiere a List Items , sólo documentos, o todos, y la categoría bajo la cual aparecerá listada nuestra Acción.
El nodo RuleDesigner específica el texto a mostrar y los parámetros de entrada. El atributo Sentence determina la sentencia que se mostrará en el Diseñador de workflows. Podemos especificar cada parámetro con un signo % seguido de un numero, por ejemplo 1, que apuntará al primer parámetro y así sucesivamente. Estos parámetros son referenciados por los FieldBind. El atributo text del mismo reemplazara a los % del Sentence. El atributo ID debe coincidir con el orden del parámetro.
Y por ultimo el nodo Parameters y sus hijos definen los parámetros que serán pasados a nuestra Custom Activity. Los nombres de los attibutos Field de los Nodos FieldBind deben coincidir exactamente con los atributos Name de los Nodos Parameters para que sean transferidos a nuestro código.
3 - Definición de nuestra clase principal.
Crearemos una clase que llamada SendMailActivity.cs o como quieran llamarla. La misma debe heredar de System.Workflow.ComponentModel.Activity (no olvidemos agregar la referencia a esta dll) la cual contiene un método llamado Execute que es el punto clave para nuestra Custom Activity que luego vamos a sobrescribir.
Ahora, definamos las propiedades en nuestra clase de modo que coincidan con lo que acabamos de establecer en el archivo .actions para que quede todo mapeado. Este mapeo se logra gracias al objeto DependencyProperty como veremos luego.
Al inicio nos ocuparemos de definir las propiedades del WorkflowContext y luego las personalizadas.
1: #region [ Workflow Context Properties ]
2:
3: public static DependencyProperty __ContextProperty = DependencyProperty.Register("__Context", typeof(WorkflowContext), typeof(SendMailActivity)); 4:
5: [ValidationOption(ValidationOption.Required)]
6: public WorkflowContext __Context
7: { 8: get
9: { 10: return ((WorkflowContext)(base.GetValue(__ContextProperty)));
11: }
12: set
13: { 14: base.SetValue(__ContextProperty, value);
15: }
16: }
17:
18: public static DependencyProperty __ListIdProperty = DependencyProperty.Register("__ListId", typeof(string), typeof(SendMailActivity)); 19:
20: [ValidationOption(ValidationOption.Required)]
21: public string __ListId
22: { 23: get
24: { 25: return ((string)(base.GetValue(__ListIdProperty)));
26: }
27: set
28: { 29: base.SetValue(__ListIdProperty, value);
30: }
31: }
32:
33: public static DependencyProperty __ListItemProperty = DependencyProperty.Register("__ListItem", typeof(int), typeof(SendMailActivity)); 34:
35: [ValidationOption(ValidationOption.Required)]
36: public int __ListItem
37: { 38: get
39: { 40: return ((int)(base.GetValue(__ListItemProperty)));
41: }
42: set
43: { 44: base.SetValue(__ListItemProperty, value);
45: }
46: }
47:
48: public static DependencyProperty __ActivationPropertiesProperty = DependencyProperty.Register("__ActivationProperties", typeof(SPWorkflowActivationProperties), typeof(SendMailActivity)); 49:
50: [ValidationOption(ValidationOption.Required)]
51: public SPWorkflowActivationProperties __ActivationProperties
52: { 53: get
54: { 55: return (SPWorkflowActivationProperties)base.GetValue(__ActivationPropertiesProperty);
56: }
57: set
58: { 59: base.SetValue(__ActivationPropertiesProperty, value);
60: }
61: }
62:
63: #endregion [ Workflow Context Properties ]
Pasemos ahora, a crear las propiedades personalizadas.
1: #region [ Custom Workflow Properties ]
2:
3: public static DependencyProperty ToProperty = DependencyProperty.Register("To", typeof(ArrayList), typeof(SendMailActivity)); 4:
5: [ValidationOption(ValidationOption.Required)]
6: public ArrayList To
7: { 8: get
9: { 10: return ((ArrayList)(base.GetValue(SendMailActivity.ToProperty)));
11: }
12: set
13: { 14: base.SetValue(SendMailActivity.ToProperty, value);
15: }
16: }
17:
18: public static DependencyProperty CCProperty = DependencyProperty.Register("CC", typeof(ArrayList), typeof(SendMailActivity)); 19:
20: [ValidationOption(ValidationOption.Optional)]
21: public ArrayList CC
22: { 23: get
24: { 25: return ((ArrayList)(base.GetValue(SendMailActivity.CCProperty)));
26: }
27: set
28: { 29: base.SetValue(SendMailActivity.CCProperty, value);
30: }
31: }
32:
33: public static DependencyProperty SubjectProperty = DependencyProperty.Register("Subject", typeof(string), typeof(SendMailActivity)); 34:
35: [ValidationOption(ValidationOption.Required)]
36: public string Subject
37: { 38: get
39: { 40: return ((string)(base.GetValue(SendMailActivity.SubjectProperty)));
41: }
42: set
43: { 44: base.SetValue(SendMailActivity.SubjectProperty, value);
45: }
46: }
47:
48: public static DependencyProperty BodyProperty = DependencyProperty.Register("Body", typeof(string), typeof(SendMailActivity)); 49:
50: [ValidationOption(ValidationOption.Optional)]
51: public string Body
52: { 53: get
54: { 55: return ((string)(base.GetValue(SendMailActivity.BodyProperty)));
56: }
57: set
58: { 59: base.SetValue(SendMailActivity.BodyProperty, value);
60: }
61: }
62:
63: public static DependencyProperty AttachmentFileNameProperty = DependencyProperty.Register("AttachmentFileName", typeof(string), typeof(SendMailActivity)); 64:
65: [ValidationOption(ValidationOption.Required)]
66: public string AttachmentFileName
67: { 68: get
69: { 70: return ((string)(base.GetValue(AttachmentFileNameProperty)));
71: }
72: set
73: { 74: base.SetValue(AttachmentFileNameProperty, value);
75: }
76: }
77:
78: public static DependencyProperty FromProperty = DependencyProperty.Register("From", typeof(string), typeof(SendMailActivity)); 79:
80: [ValidationOption(ValidationOption.Required)]
81: public string From
82: { 83: get
84: { 85: return ((string)(base.GetValue(SendMailActivity.FromProperty)));
86: }
87: set
88: { 89: base.SetValue(SendMailActivity.FromProperty, value);
90: }
91: }
92:
93:
94: #endregion [ Custom Workflow Properties ]
Ahora bien, podemos avanzar en la creación de nuestro método. Aquí explicaré con más detalle la solución.
Lo que necesitabamos hacer para poder adjuntar el formulario infopath a un email era acceder físicamente al mismo. El problema de esto era que no tenia forma de obtener la referencia hacia ninguna ruta “física” del item (formulario), pero sí, por medio del contexto del Workflow, podía saber cual era su ubicación lógica (Url) desde la propiedad CurrentItemUrl. Aquí llegamos al tema más interesante de este post.
Primer paso importante:
Como podrán ver luego, se usó la url del ítem actual para crear un WebRequest a partir del cual podía obtener un ResponseStream. Algo bastante sugestivo, y algo que no podemos olvidar de hacer al crear el Request para un formulario InfoPath, es enviar un parámetro en el header del mensaje de modo que cuando se ejecute, este no sea redireccionado a FormServer.aspx (redirección por default cuando accedemos a un archivo infopath). El modo de enviar este parámetro es el siguiente: myRequest.Headers.Add("Translate:f");.
Les dejo un link referente a este ultimo tema. Parámetros con los que puede trabajar InfoPath Forms.
http://msdn.microsoft.com/en-us/library/ie/ms772417.aspx
Segundo paso importante:
Luego de obtener el Stream para el adjunto, se crea el Attachment del email, estableciendo para su ContentType correspondiente. Ejemplo:
1: Attachment form = new Attachment(msForm, new ContentType("application/x-microsoft-InfoPathForm"));
Tercer paso importante:
Agregar los siguientes headers al Mensaje:
1: message.Headers.Add("Content-Class", "InfoPathForm.InfoPath"); 2: message.Headers.Add("Message-Class", "IPM.InfoPathForm.InfoPath");
Como expliqué en los párrafos anteriores, nuestra clase hereda de Activity. Esto implica que estamos obligados a implementar el método Execute sobrescribiéndolo con nuestra propia lógica. En el siguiente ejemplo podemos ver como quedaría nuestro código siguiendo las indicaciones anteriores:
1: protected override ActivityExecutionStatus Execute(ActivityExecutionContext executionContext)
2: { 3: using (var web = __Context.Web)
4: { 5: try
6: { 7: // Obtiene la informacion necesaria para el mensaje que se envía,
8: //relacionada al item actual sobre el cual esta corriendo el workflow.
9: var message = BuildMailMessage(web);
10:
11: try
12: { 13: using (SPSite site = new SPSite(AttachmentFileName))
14: { 15: using (SPWeb fileWeb = site.OpenWeb())
16: { 17: string nameForm;
18:
19: //Stream del Archivo InfoPath xml
20: Stream msForm = CreateResponseStream(fileWeb, AttachmentFileName, out nameForm);
21:
22: Attachment form = new Attachment(msForm, new ContentType("application/x-microsoft-InfoPathForm")); 23:
24: message.Headers.Add("Content-Class", "InfoPathForm.InfoPath"); 25: message.Headers.Add("Message-Class", "IPM.InfoPathForm.InfoPath"); 26:
27: message.Attachments.Add(form);
28:
29: }
30: }
31:
32: }
33: catch (Exception ex)
34: { 35: // NO se pudo encontrar e archivo.
36: Common.WriteFailToHistoryLog(web, WorkflowInstanceId, string.Format(CultureInfo.InvariantCulture, "No se pudo adjuntar el archivo: '{0}' - Mensaje: {1}.", AttachmentFileName, ex.Message)); 37: }
38:
39: if (!string.IsNullOrEmpty(From))
40: { 41: //Se obtiene la cuenta del usuario enviador del mail
42: message.From = GetMailAddress(__Context.Web, From);
43: Common.WriteFailToHistoryLog(web, WorkflowInstanceId, string.Format(CultureInfo.InvariantCulture, "From: {0}", message.From)); 44: }
45:
46: if (message.To.Count > 0)
47: { 48: // Se establece el objeto SMTP en base a la configuracion del smtp de nuestro Sharepoint
49: SmtpClient smtpClient = LoadSmtpInformation();
50:
51: if (message != null)
52: Common.WriteFailToHistoryLog(web, WorkflowInstanceId, string.Format(CultureInfo.InvariantCulture, "Direccion de mail del From: {0} - To: {1} - ", message.From.Address, message.To[0].Address)); 53: smtpClient.Send(message);
54:
55: // Log en el historial del Workflow
56: Common.WriteSuccessToHistoryLog(web, WorkflowInstanceId, string.Format(CultureInfo.InvariantCulture, "Email correctamente enviado a los usuarios: {0}", message.To)); 57: }
58: else
59: { 60: // Log en el historial del Workflow
61: StringBuilder emailAddressesTo = new StringBuilder();
62: for (int i = 0; i < To.Count; i++)
63: { 64: emailAddressesTo.AppendFormat(CultureInfo.InvariantCulture, "{0}, ", To[i]); 65: }
66: // Trim de la utlima coma
67: emailAddressesTo = emailAddressesTo.Remove(emailAddressesTo.Length - 1, 2);
68:
69:
70: Common.WriteFailToHistoryLog(web, WorkflowInstanceId, string.Format(CultureInfo.InvariantCulture, "Unable to send email out. No valid user email addresses for the following users ({0}) were found.", emailAddressesTo)); 71: }
72: }
73: catch (Exception ex)
74: { 75: // Log en el historial del Workflow
76: Common.WriteFailToHistoryLog(web, WorkflowInstanceId, string.Format(CultureInfo.InvariantCulture, "Falló el envío de mail. Mensaje de Error: {0}", ex.Message)); 77: }
78: finally
79: { 80: // Cleanup - Dispose de las propiedades privadas antes de salir
81: if (__ActivationProperties != null)
82: { 83: __ActivationProperties.Dispose();
84: }
85:
86: if (__Context != null)
87: { 88: __Context.Dispose();
89: }
90: }
91: }
92:
93: return ActivityExecutionStatus.Closed;
94: }
95:
96: private Stream CreateResponseStream(SPWeb fileWeb, string nameFile, out string name)
97: { 98: SPFile attachment = null;
99: attachment = fileWeb.GetFile(nameFile);
100: name = attachment.Name;
101: WebRequest req = WebRequest.Create(nameFile);
102: req.Headers.Add("Translate:f"); 103: req.Credentials = CredentialCache.DefaultNetworkCredentials;
104: WebResponse response = req.GetResponse();
105: Stream msForm = response.GetResponseStream();
106:
107: return msForm;
108: }
109:
110: private static SmtpClient LoadSmtpInformation()
111: { 112: string smtpServer = SPAdministrationWebApplication.Local.OutboundMailServiceInstance.Server.Address;
113: return new SmtpClient(smtpServer);
114: }
Veamos un poco el código. Primero obtenemos el Contexto Web actual. Luego usando algunos métodos helpers construimos el mensaje (BuildMailMessage)
Después de crear el mensaje tratamos de cargar el adjunto especificado por la propiedad. Para esto usamos la propiedad AttachmentFileName para abrir el SPSite donde el archivo reside. Luego abrimos un SPWeb para obtener el archivo en cuestión. (dentro del método CreateResponseStream).
Desarrollamos los tres pasos definidos en los párrafos anteriores. Y finalmente establecemos el SMTP de nuestro Sharepoint (LoadSmtpInformation ) y con el método GetMailAddress obtenemos las direcciones de correo en caso de que en los parámetros nos llegue el Account Name.
4 - Registración de Custom Action.
Antes de usar nuestra Custom Action debemos, primero, registrarla en el Web.config de nuestro Sharepoint. Para hacer esto usaremos un Feature Reciver que ejecute esta acción durante la activación.
Como agregar un EventReciver:
Esto nos generará una clase Vemn.EventReceiver.cs en la cual vamos a implementar los siguientes métodos:
1: public override void FeatureActivated(SPFeatureReceiverProperties properties)
2: { 3: SPWebApplication webapp = (SPWebApplication)properties.Feature.Parent;
4: UpdateWebConfig(webapp, true);
5:
6: }
7:
8: public override void FeatureDeactivating(SPFeatureReceiverProperties properties)
9: { 10: SPWebApplication webapp = (SPWebApplication)properties.Feature.Parent;
11: UpdateWebConfig(webapp, false);
12:
13: }
14:
15: private void UpdateWebConfig(SPWebApplication webApp, bool featureActivated)
16: { 17: SPWebConfigModification modification =
18: new SPWebConfigModification("authorizedType[@Assembly=\"Vemn.CustomActivity, Version=1.0.0.0, Culture=neutral, PublicKeyToken=7819e4834cee4b0e\"][@Namespace=\"Vemn.CustomActivity\"][@TypeName=\"*\"][@Authorized=\"True\"]", "configuration/System.Workflow.ComponentModel.WorkflowCompiler/authorizedTypes"); 19:
20: modification.Owner = "Vemn.CustomActivity";
21: modification.Sequence = 0;
22: modification.Type = SPWebConfigModification.SPWebConfigModificationType.EnsureChildNode;
23: modification.Value =
24: string.Format(CultureInfo.InstalledUICulture,
25: "<authorizedType Assembly=\"{0}\" Namespace=\"{1}\" TypeName=\"{2}\" Authorized=\"{3}\"/>", 26: new object[] { "Vemn.CustomActivity, Version=1.0.0.0, Culture=neutral, PublicKeyToken=7819e4834cee4b0e", "Vemn.CustomActivity", "*", "True" }); 27:
28: if (featureActivated)
29: webApp.WebConfigModifications.Add(modification);
30: else
31: webApp.WebConfigModifications.Remove(modification);
32:
33:
34: SPFarm.Local.Services.GetValue<SPWebService>().ApplyWebConfigModifications();
35: }
Esto agregará o removerá un nodo authorizedType en el Web.config de nuestro Sharepoint, para habilitar el uso de nuestra Action en el Workflow Designer.
Agregar una entrada Safecontrol en el manifiesto de nuestro Package.
Para asegurarnos de que Sharepoint Cargue de manera segura nuestra CustomAction es que añadimos la entrada SafeControl sobrescribiendo el archivo Package.Template.xml de la siguiente manera:
1: <Assemblies>
2: <Assembly Location="Vemn.CustomActivity.dll" DeploymentTarget="GlobalAssemblyCache">
3: <SafeControls>
4: <SafeControl Assembly="$SharePoint.Project.AssemblyFullName$" Namespace="$SharePoint.Project.FileNameWithoutExtension$" TypeName="*" />
5: </SafeControls>
6: </Assembly>
7: </Assemblies>
Ahora si ya estamos en condiciones de compilar, hacer el deploy y testear.
Creamos un Workflow Reusable y revisamos si nuestra Custom Acrivity está disponible para usar.
Deberíamos poder seleccionarla e insertarla como sentencia.
Conclusión
Como pudimos ver, Sharepoint 2010 nos permite extender las capacidades de la versión OOB. Esto sumado a los Workflows Reusables nos dan una gran flexibilidad para el diseño y desarrollo de soluciones que exigen considerados niveles de personalización.